From 90a3f41c2da1a6ea75ccee338e5d5eebb0e55e1b Mon Sep 17 00:00:00 2001 From: Zachary Dziura Date: Mon, 6 Aug 2018 18:01:40 -0400 Subject: [PATCH] Release v1.0.0 This is a monumental release in the development of sterling, culminating in the finalization of the command-line API and a stabilization of sterling's features. Woo-hoo!! Sterling's functionality is very basic: conversion of PHB currencies into user-defined ones, basic arithmetic operations, and converting custom currency amounts to the equivalent amount in PHB copper. It's unlikely that I will be adding any additional features ontop of this set, as it already provides functionality appropriate enough for most usecases. Congrats to me on finally finishing a side-project! --- .gitlab-ci.yml | 14 +-- .travis.yml | 73 +++++++++---- Cargo.toml | 23 +++- README.md | 1 - appveyor.yml | 73 +++++++++---- benches/bench.rs | 27 +++++ ci/before_deploy.ps1 | 22 ++++ ci/before_deploy.sh | 32 ++++++ ci/install.sh | 14 +++ ci/script.sh | 18 ++++ src/config.rs | 43 ++++++-- src/convert.rs | 208 ++++++++++++++---------------------- src/currency.rs | 36 +------ src/lib.rs | 244 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 241 ++++++++++++++++++------------------------ 15 files changed, 706 insertions(+), 363 deletions(-) create mode 100644 benches/bench.rs create mode 100644 ci/before_deploy.ps1 create mode 100644 ci/before_deploy.sh create mode 100644 ci/install.sh create mode 100644 ci/script.sh create mode 100644 src/lib.rs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dc4765a..b5683be 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,16 +9,4 @@ cargo:test: - master script: - cargo test --verbose - -cargo:build: - stage: deploy - only: - - master - script: - - cargo build --release - cache: - paths: - - target/ - artifacts: - paths: - - target/release/sterling \ No newline at end of file + - cargo bench \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 8043cbb..6144d69 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,27 +1,62 @@ +# Based on the "trust" template v0.1.2 +# https://github.com/japaric/trust/tree/v0.1.2 + +dist: trusty language: rust -os: - - osx -branches: - only: - - master -rust: - - stable +services: docker +sudo: required + +env: + global: + # TODO Update this to match the name of your project. + - CRATE_NAME=sterling + +matrix: + include: + # Linux + - env: TARGET=x86_64-unknown-linux-gnu + + # OSX + - env: TARGET=x86_64-apple-darwin + os: osx + +before_install: + - set -e + - rustup self update + +install: + - sh ci/install.sh + - source ~/.cargo/env || true + script: - - cargo test --verbose - - cargo build --release + - bash ci/script.sh + +after_script: set +e + before_deploy: - - git config --local user.name "Zachary Dziura" - - git config --local user.email "zcdziura@gmail.com" - - git tag "$(date +'%Y%m%d%H%M%S')-$(git log --format=%h -1)-osx" + - sh ci/before_deploy.sh + deploy: - provider: releases api_key: secure: "NVH5d3CH0QUyFSu0MbeB3WvSo52qwjxH98wIL7kieD/kbTrTzTCbTWiTCV+/OM41nWOdxTy9T5TLqDsrh4k4xCkb4VbKTcsfIpBaQqwMvT6Co/GgLr4xSiHBI/ENBVwbnyDavMxh/E5AAAPF/HgGci2tEqzNuu9V7jon6uhb8+WovbfZeEA4tSNLsWV5g3MwssMfdaWzDPTHsiWXFPn6AVhkmy4fKAIHoUtp37A7bqx1hGPpFD3OGYN1oDxtJK5jRBSXegyWh08RQkLQ74PJTWD6Xw+Hvp1ewP1vitP69VJgsBC496jPasqAEOVeD3KogtcmBEyaIG+I5LZWLTibs41qF83cxJDdWxw69H827IXSQobM+7Sc51chWJR0H3OA1yDPQvorI1C17zvXd4wPpDfSUeY5ZqAplnYMOxk3jDbbX099bEyRE/skWHRaqL99fV7i5bO3aHDFP/BDjp03hnzpvfKs9zm05e87LStriNYQ5NsCPkdX+W18Q15DLhS2D9cp37PPAUA5jLNUFiEY5x9fwl5XEpefBqrqmE8qbmkc9GTr3MZikmTfB51Nx5NvkybCTKhMoKw5AhNLmw0fnkaqxrei7Uif7WqxTkngJep6VLidmt2pRJ9Qj3AWOXsLZJPm0ZQuo71dWC049EeEVtfQkyz/9K2J+iNVRgdiEeg=" file_glob: true - file: target/release/sterling - skip_cleanup: true + file: $CRATE_NAME-$TRAVIS_TAG-$TARGET.* on: - repo: zcdziura/sterling - branch: master -addons: - artifacts: true + tags: true + provider: releases + skip_cleanup: true + +cache: cargo +before_cache: + # Travis can't cache files that are not readable by "others" + - chmod -R a+r $HOME/.cargo + +branches: + only: + # release tags + - /^v\d+\.\d+\.\d+.*$/ + # - master + +notifications: + email: + on_success: never \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 69ef54a..d984979 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sterling" -version = "0.3.0" +version = "1.0.0" description = "Converts a given D&D 5e currency value to the Silver Standard." authors = ["Zachary Dziura "] readme = "README.md" @@ -8,14 +8,31 @@ license = "Unlicense/MIT" repository = "https://gitlab.com/zcdziura/sterling" keywords = ["dnd", "coins", "converter", "currency", "5e"] +[lib] +name = "sterling_ops" +path = "src/lib.rs" + +[[bin]] +name = "sterling" +path = "src/main.rs" + [dependencies] -clap = "2.31" +clap = "2.32" +lazysort = "0.2" lazy_static = "1.0" regex = "1.0" +separator = "0.3" serde = "1.0" serde_derive = "1.0" serde_yaml = "0.7" +[dev-dependencies] +criterion = "0.2" + [profile.release] lto = true -panic = "abort" \ No newline at end of file +panic = "abort" + +[[bench]] +name = "bench" +harness = false \ No newline at end of file diff --git a/README.md b/README.md index 29fc6db..e9b7270 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,6 @@ within my own campaign! name: "guilder" rate: 896 alias: "g" - plural: "guilders" - name: "shilling" rate: 32 diff --git a/appveyor.yml b/appveyor.yml index d994f41..01e0145 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,29 +1,60 @@ -os: Visual Studio 2015 -branches: - only: - - master +# Based on the "trust" template v0.1.2 +# https://github.com/japaric/trust/tree/v0.1.2 + environment: + global: + RUST_VERSION: stable + CRATE_NAME: sterling + matrix: - - channel: stable - target: x86_64-pc-windows-gnu -artifacts: - - path: target/release/sterling.exe - name: sterling + # MinGW + - TARGET: x86_64-pc-windows-gnu + install: - - appveyor DownloadFile https://win.rustup.rs/ -FileName rustup-init.exe - - rustup-init -yv --default-toolchain %channel% --default-host %target% - - set PATH=%PATH%;%USERPROFILE%\.cargo\bin - - rustc -vV - - cargo -vV -build: false + - ps: >- + If ($Env:TARGET -eq 'x86_64-pc-windows-gnu') { + $Env:PATH += ';C:\msys64\mingw64\bin' + } ElseIf ($Env:TARGET -eq 'i686-pc-windows-gnu') { + $Env:PATH += ';C:\msys64\mingw32\bin' + } + - curl -sSf -o rustup-init.exe https://win.rustup.rs/ + - rustup-init.exe -y --default-host %TARGET% --default-toolchain %RUST_VERSION% + - set PATH=%PATH%;C:\Users\appveyor\.cargo\bin + - rustc -Vv + - cargo -V + test_script: - - cargo test --verbose - - cargo build --release + # we don't run the "test phase" when doing deploys + - if [%APPVEYOR_REPO_TAG%]==[true] ( + cargo test --target %TARGET% && + cargo build --target %TARGET% --release + ) + +before_deploy: + - ps: ci\before_deploy.ps1 + deploy: - provider: GitHub - description: '' + artifact: /.*\.zip/ auth_token: secure: bvA/4J1T0h65ur6tsg6k/wlZFjP3qr2QsyRsmGMEmm7DOF61xmzTnjuBcPjQYrba - artifact: target/release/sterling.exe on: - branch: master \ No newline at end of file + RUST_VERSION: stable + appveyor_repo_tag: true + provider: GitHub + +cache: + - C:\Users\appveyor\.cargo\registry + - target + +branches: + only: + # Release tags + - /^v\d+\.\d+\.\d+.*$/ + # - master + +notifications: + - provider: Email + on_build_success: false + +# Building is done in the test phase, so we disable Appveyor's build phase. +build: false \ No newline at end of file diff --git a/benches/bench.rs b/benches/bench.rs new file mode 100644 index 0000000..5695a90 --- /dev/null +++ b/benches/bench.rs @@ -0,0 +1,27 @@ +#[macro_use] +extern crate criterion; +#[macro_use] +extern crate lazy_static; +extern crate sterling_ops; + +use criterion::Criterion; +use sterling_ops::currency::Currency; +use sterling_ops::*; + +lazy_static! { + static ref CURRENCIES: Vec = vec![ + Currency::new("penny", 1, "p", Some("pence".to_owned()), None), + Currency::new("shilling", 100, "s", Some("sterling".to_owned()), None), + Currency::new("guilder", 10_000, "g", None, None), + Currency::new("note", 1_000_000, "N", None, Some(true)), + ]; +} + +fn criterion_benchmark(c: &mut Criterion) { + c.bench_function("default operation", |b| { + b.iter(|| default_operation("3p 5s 7s 132c", &CURRENCIES, true)) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/ci/before_deploy.ps1 b/ci/before_deploy.ps1 new file mode 100644 index 0000000..596b39d --- /dev/null +++ b/ci/before_deploy.ps1 @@ -0,0 +1,22 @@ +# This script takes care of packaging the build artifacts that will go in the +# release zipfile + +$SRC_DIR = $PWD.Path +$STAGE = [System.Guid]::NewGuid().ToString() + +Set-Location $ENV:Temp +New-Item -Type Directory -Name $STAGE +Set-Location $STAGE + +$ZIP = "$SRC_DIR\$($Env:CRATE_NAME)-$($Env:APPVEYOR_REPO_TAG_NAME)-$($Env:TARGET).zip" + +Copy-Item "$SRC_DIR\target\$($Env:TARGET)\release\sterling.exe" '.\' + +7z a "$ZIP" * + +Push-AppveyorArtifact "$ZIP" + +Remove-Item *.* -Force +Set-Location .. +Remove-Item $STAGE +Set-Location $SRC_DIR diff --git a/ci/before_deploy.sh b/ci/before_deploy.sh new file mode 100644 index 0000000..4dc1d7d --- /dev/null +++ b/ci/before_deploy.sh @@ -0,0 +1,32 @@ +# This script takes care of building your crate and packaging it for release + +set -ex + +main() { + local src=$(pwd) \ + stage= + + case $TRAVIS_OS_NAME in + linux) + stage=$(mktemp -d) + ;; + osx) + stage=$(mktemp -d -t tmp) + ;; + esac + + test -f Cargo.lock || cargo generate-lockfile + + cargo build --release --target $TARGET + + # TODO Update this to package the right artifacts + cp target/$TARGET/release/sterling $stage/ + + cd $stage + tar czf $src/$CRATE_NAME-$TRAVIS_TAG-$TARGET.tar.gz * + cd $src + + rm -rf $stage +} + +main \ No newline at end of file diff --git a/ci/install.sh b/ci/install.sh new file mode 100644 index 0000000..f0b4171 --- /dev/null +++ b/ci/install.sh @@ -0,0 +1,14 @@ +set -ex + +main() { + local target= + if [ $TRAVIS_OS_NAME = linux ]; then + target=x86_64-unknown-linux-gnu + sort=sort + else + target=x86_64-apple-darwin + sort=gsort # for `sort --sort-version`, from brew's coreutils. + fi +} + +main diff --git a/ci/script.sh b/ci/script.sh new file mode 100644 index 0000000..7d36d68 --- /dev/null +++ b/ci/script.sh @@ -0,0 +1,18 @@ +# This script takes care of testing your crate + +set -ex + +main() { + cross build --target $TARGET --release + + if [ ! -z $DISABLE_TESTS ]; then + return + fi + + cross test --target $TARGET +} + +# we don't run the "test phase" when doing deploys +if [ -z $TRAVIS_TAG ]; then + main +fi diff --git a/src/config.rs b/src/config.rs index 162164f..fe63a9c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,16 +10,47 @@ use currency::Currency; pub fn load_config(filename: &str) -> Result, ConfigError> { let config_file = File::open(filename)?; - let mut configs: Vec = serde_yaml::from_reader(BufReader::new(config_file))?; - configs.sort_by(|a, b| b.cmp(a)); + let config: Vec = serde_yaml::from_reader(BufReader::new(config_file))?; - Ok(configs) + Ok(config) } -pub fn default_config() -> Vec { +pub fn parse_currency_config( + config_result: Result, ConfigError>, + config_file_path: Option<&str>, +) -> Result, String> { + match config_result { + Ok(values) => Ok(values), + Err(error) => match error.kind { + ErrorKind::NotFound => { + if let Some(file_path) = config_file_path { + Err(format!( + "Sterling Error: Can't find configuration file: \"{}\"", + &file_path + )) + } else { + Ok(silver_standard_config()) + } + } + _ => Err(format!("Sterling Error: {}", error)), + }, + } +} + +pub fn phb_config() -> Vec { vec![ - Currency::new("platinum", 1000000, "p", None, None), - Currency::new("gold", 10000, "g", None, None), + Currency::new("platinum", 1000, "p", None, None), + Currency::new("gold", 100, "g", None, None), + Currency::new("electrum", 50, "e", None, Some(true)), + Currency::new("silver", 10, "s", None, None), + Currency::new("copper", 1, "c", None, None), + ] +} + +fn silver_standard_config() -> Vec { + vec![ + Currency::new("platinum", 1_000_000, "p", None, None), + Currency::new("gold", 10_000, "g", None, None), Currency::new("silver", 100, "s", None, None), Currency::new("copper", 1, "c", None, None), ] diff --git a/src/convert.rs b/src/convert.rs index 3cad37d..174f67f 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,155 +1,109 @@ -use currency::Currency; +use std::collections::HashMap; +use std::str::FromStr; + use regex::Regex; +use separator::Separatable; -pub fn convert_to_copper(amount: usize, coin_denomination: &str) -> usize { - match coin_denomination { - "p" => amount * 1000, - "g" => amount * 100, - "e" => amount * 50, - "s" => amount * 10, - "c" => amount, - _ => unreachable!("Invalid coin type; must be a valid coin found in the PHB."), - } +use currency::Currency; +use lazysort::Sorted; + +pub fn calculate_total_copper_value( + values: &str, + currency_regex: &Regex, + rates: HashMap, +) -> usize { + currency_regex + .captures_iter(&values) + .fold(0, |sum, capture| { + let value: usize = str::replace(&capture[1], ",", "").parse().unwrap(); + let rate: &usize = rates.get(&capture[2]).unwrap(); + let product = value * rate; + + sum + product + }) } -pub fn calculate_total_copper_value(coins: Vec<&str>) -> Result { - let regex: Regex = Regex::new(r"(\d+)([cegps])").unwrap(); - for coin in coins.iter() { - if let None = regex.captures(coin) { - return Err( - "Sterling Error: Invalid coin value. Make sure all coins are denoted properly.", - ); - } - } - - let converted_values = coins.iter().map(|coin| { - let captures = regex.captures(coin).unwrap(); - let amount: usize = captures[1].parse().unwrap(); - let denomination = captures[2].to_owned(); - convert_to_copper(amount, &denomination) - }); - - Ok(converted_values.fold(0 as usize, |total, value| total + value)) -} - -pub fn convert_currencies(copper_value: usize, currencies: Vec) -> Vec { - exchange(copper_value, currencies) - .iter() - .filter(|c| (*c).value.unwrap_or(0) > 0) - .cloned() - .collect() -} - -pub fn exchange(copper: usize, mut currencies: Vec) -> Vec { - let mut val = copper; +pub fn exchange_currencies( + copper_value: usize, + currencies: &[Currency], + print_full_name: bool, +) -> Vec { + let mut val = copper_value; currencies - .iter_mut() + .iter() + .sorted() + .filter(|currency| currency.optional.unwrap_or(true)) .map(|currency| { let value = val / currency.rate; val = val % currency.rate; - currency.with_value(value) + ( + value, + if print_full_name { + if value > 1 { + match (¤cy).plural { + Some(ref plural) => String::from_str(plural).unwrap(), + None => format!("{}s", ¤cy.name), + } + } else { + String::from_str(¤cy.name).unwrap() + } + } else { + String::from_str(¤cy.alias).unwrap() + }, + ) + }) + .filter(|tuple| tuple.0 > 0) + .map(|tuple| { + format!( + "{}{}{}", + tuple.0.separated_string(), + if tuple.1.len() > 1 { " " } else { "" }, + tuple.1 + ) }) .collect() } #[cfg(test)] mod tests { - use convert::*; + use std::collections::HashMap; + + use super::{calculate_total_copper_value, exchange_currencies}; use currency::Currency; + use regex::Regex; lazy_static! { - static ref STANDARD_CURRENCIES: [Currency; 4] = [ - Currency::new("platinum", 1000000, "p", None, None), - Currency::new("gold", 10000, "g", None, None), - Currency::new("silver", 100, "s", None, None), - Currency::new("copper", 1, "c", None, None), + static ref STANDARD_CURRENCIES: Vec = vec![ + Currency::new("guilder", 10_000, "g", None, None), + Currency::new("shilling", 100, "s", Some("sterling".to_owned()), None), + Currency::new("penny", 1, "p", Some("pence".to_owned()), None), ]; - } - - #[test] - fn test_convert_copper_to_copper() { - assert_eq!(1, convert_to_copper(1, "c")); - } - - #[test] - fn test_convert_silver_to_copper() { - assert_eq!(10, convert_to_copper(1, "s")); - } - - #[test] - fn test_convert_electrum_to_copper() { - assert_eq!(50, convert_to_copper(1, "e")); - } - - #[test] - fn test_convert_gold_to_copper() { - assert_eq!(100, convert_to_copper(1, "g")); - } - - #[test] - fn test_convert_platinum_to_copper() { - assert_eq!(1000, convert_to_copper(1, "p")); + static ref CURRENCY_REGEX: Regex = Regex::new(r"(\d+)([Ngsp])").unwrap(); } #[test] fn test_calculate_total_copper_value() { - let values = vec!["1p", "1g", "1e", "1s", "1c"]; - assert_eq!(1161, calculate_total_copper_value(values).unwrap()); + let rates: HashMap = vec![ + ("g".to_owned(), 10_000usize), + ("s".to_owned(), 100usize), + ("p".to_owned(), 1usize), + ].into_iter() + .collect(); + + let result = 10101usize; + assert_eq!( + result, + calculate_total_copper_value("1g 1s 1p", &CURRENCY_REGEX, rates) + ); } #[test] - #[should_panic] - fn test_calculate_total_copper_value_bad_inputs() { - let values = vec!["1p", "1g", "1f", "1s", "1c"]; - assert_eq!(1161, calculate_total_copper_value(values).unwrap()); - } - - #[test] - fn test_exchange_to_copper() { - let currencies = vec![ - Currency::new("platinum", 1000000, "p", None, None).with_value(0), - Currency::new("gold", 10000, "g", None, None).with_value(0), - Currency::new("silver", 100, "s", None, None).with_value(0), - Currency::new("copper", 1, "c", None, None).with_value(1), - ]; - - assert_eq!(currencies, exchange(1, STANDARD_CURRENCIES.to_vec())); - } - - #[test] - fn test_exchange_to_silver() { - let currencies = vec![ - Currency::new("platinum", 1000000, "p", None, None).with_value(0), - Currency::new("gold", 10000, "g", None, None).with_value(0), - Currency::new("silver", 100, "s", None, None).with_value(1), - Currency::new("copper", 1, "c", None, None).with_value(0), - ]; - - assert_eq!(currencies, exchange(100, STANDARD_CURRENCIES.to_vec())); - } - - #[test] - fn test_exchange_to_gold() { - let currencies = vec![ - Currency::new("platinum", 1000000, "p", None, None).with_value(0), - Currency::new("gold", 10000, "g", None, None).with_value(1), - Currency::new("silver", 100, "s", None, None).with_value(0), - Currency::new("copper", 1, "c", None, None).with_value(0), - ]; - - assert_eq!(currencies, exchange(10000, STANDARD_CURRENCIES.to_vec())); - } - - #[test] - fn test_exchange_to_platinum() { - let currencies = vec![ - Currency::new("platinum", 1000000, "p", None, None).with_value(1), - Currency::new("gold", 10000, "g", None, None).with_value(0), - Currency::new("silver", 100, "s", None, None).with_value(0), - Currency::new("copper", 1, "c", None, None).with_value(0), - ]; - - assert_eq!(currencies, exchange(1000000, STANDARD_CURRENCIES.to_vec())); + fn test_exchange_currencies() { + let result = vec!["1g".to_owned(), "1s".to_owned(), "1p".to_owned()]; + assert_eq!( + result, + exchange_currencies(10101, &STANDARD_CURRENCIES, false) + ); } } diff --git a/src/currency.rs b/src/currency.rs index 508db70..7f95e56 100644 --- a/src/currency.rs +++ b/src/currency.rs @@ -4,7 +4,6 @@ use std::cmp::Ordering; pub struct Currency { pub name: String, pub rate: usize, - pub value: Option, pub alias: String, pub plural: Option, pub optional: Option, @@ -21,54 +20,23 @@ impl Currency { Currency { name: name.to_owned(), rate, - value: None, alias: alias.to_owned(), plural, optional, } } - pub fn with_value(&mut self, value: usize) -> Currency { - Currency { - name: self.name.clone(), - rate: self.rate, - value: Some(value), - alias: self.alias.clone(), - plural: self.plural.clone(), - optional: None, - } - } - pub fn is_optional(&self) -> bool { match self.optional { Some(optional) => optional, None => false, } } - - pub fn alias_display(&self) -> String { - self.value.unwrap_or(0).to_string() + &self.alias - } - - pub fn full_display(&self) -> String { - let mut display = self.value.unwrap_or(0).to_string() + " "; - - if self.value.unwrap_or(0) > 1 { - match &self.plural { - &Some(ref plural) => display = display + &plural, - &None => display = display + &self.name, - } - } else { - display = display + &self.name; - } - - display - } } impl Ord for Currency { fn cmp(&self, other: &Currency) -> Ordering { - self.value.cmp(&other.value) + other.rate.cmp(&self.rate) } } @@ -80,6 +48,6 @@ impl PartialOrd for Currency { impl PartialEq for Currency { fn eq(&self, other: &Currency) -> bool { - self.value == other.value + self.rate == other.rate } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..640ccdf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,244 @@ +extern crate lazysort; +#[allow(unused_imports)] +#[macro_use] +extern crate lazy_static; +extern crate regex; +extern crate separator; +extern crate serde; +#[macro_use] +extern crate serde_derive; +extern crate serde_yaml; + +use std::collections::HashMap; + +pub mod config; +mod convert; +pub mod currency; + +use currency::Currency; +use regex::Regex; +use separator::Separatable; + +pub fn add_operation( + augend: &str, + addend: &str, + custom_currency_regex: &Regex, + currencies: &[Currency], + print_full: bool, +) -> String { + let lhs = + convert::calculate_total_copper_value(augend, custom_currency_regex, get_rates(currencies)); + + let rhs = convert::calculate_total_copper_value( + addend, + &custom_currency_regex, + get_rates(currencies.as_ref()), + ); + + convert::exchange_currencies(lhs + rhs, currencies, print_full).join(", ") +} + +pub fn sub_operation( + minuend: &str, + subtrahend: &str, + custom_currency_regex: &Regex, + currencies: &[Currency], + print_full: bool, +) -> String { + let lhs = convert::calculate_total_copper_value( + minuend, + custom_currency_regex, + get_rates(currencies), + ); + + let rhs = convert::calculate_total_copper_value( + subtrahend, + custom_currency_regex, + get_rates(currencies), + ); + + let difference = if lhs > rhs { lhs - rhs } else { rhs - lhs }; + convert::exchange_currencies(difference, currencies, print_full).join(", ") +} + +pub fn mul_operation( + multiplicand: &str, + multiplier: usize, + custom_currency_regex: &Regex, + currencies: &[Currency], + print_full: bool, +) -> String { + let lhs = convert::calculate_total_copper_value( + multiplicand, + custom_currency_regex, + get_rates(currencies), + ); + + convert::exchange_currencies(lhs * multiplier, currencies, print_full).join(", ") +} + +pub fn div_operation( + dividend: &str, + divisor: usize, + custom_currency_regex: &Regex, + currencies: &[Currency], + print_full: bool, +) -> String { + let lhs = convert::calculate_total_copper_value( + dividend, + custom_currency_regex, + get_rates(currencies), + ); + + convert::exchange_currencies(lhs / divisor, currencies, print_full).join(", ") +} + +pub fn copper_operation( + values: &str, + custom_currency_regex: &Regex, + currencies: &[Currency], +) -> String { + let copper_value = + convert::calculate_total_copper_value(values, custom_currency_regex, get_rates(currencies)); + + format!("{}c", copper_value.separated_string()) +} + +pub fn default_operation(values: &str, currencies: &[Currency], print_full: bool) -> String { + let copper_value = convert::calculate_total_copper_value( + values, + &Regex::new(r"(0|(?:[1-9](?:\d+|\d{0,2}(?:,\d{3})*)))+([cegps])").unwrap(), + get_rates(config::phb_config().as_ref()), + ); + + let exchanged_currencies = convert::exchange_currencies(copper_value, currencies, print_full); + + exchanged_currencies.join(", ") +} + +fn get_rates(currencies: &[Currency]) -> HashMap { + currencies + .iter() + .map(|c| c.alias.clone()) + .zip(currencies.iter().map(|c| c.rate)) + .collect() +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use currency::Currency; + use regex::Regex; + + use super::{ + add_operation, copper_operation, default_operation, div_operation, get_rates, + mul_operation, sub_operation, + }; + + lazy_static! { + static ref CURRENCIES: Vec = vec![ + Currency::new("penny", 1, "p", Some("pence".to_owned()), None), + Currency::new("shilling", 100, "s", Some("sterling".to_owned()), None), + ]; + static ref CUSTOM_CURRENCY_REGEX: Regex = Regex::new(&format!( + "(\\d+)([{}])", + CURRENCIES + .iter() + .map(|c| c.alias.clone()) + .fold(String::new(), |group, a| group + &a) + )).unwrap(); + } + + #[test] + fn test_get_rates() { + let rates: HashMap<_, _> = vec![("p".to_owned(), 1usize), ("s".to_owned(), 100usize)] + .into_iter() + .collect(); + assert_eq!(rates, get_rates(&CURRENCIES)); + } + + #[test] + fn test_add_operation_same_currencies() { + let result = "3p".to_owned(); + assert_eq!( + result, + add_operation("1p", "2p", &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_add_operation_diff_currencies() { + let result = "1s, 1p".to_owned(); + assert_eq!( + result, + add_operation("1s", "1p", &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_sub_operation_smaller_subtrahend() { + let result = "1p".to_owned(); + assert_eq!( + result, + sub_operation("2p", "1p", &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_sub_operation_larger_subtrahend() { + let result = "1p".to_owned(); + assert_eq!( + result, + sub_operation("1p", "2p", &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_mul_operation() { + let result = "6p".to_owned(); + assert_eq!( + result, + mul_operation("3p", 2, &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_div_operation_even_dividend() { + let result = "2p".to_owned(); + assert_eq!( + result, + div_operation("4p", 2, &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_div_operation_odd_dividend() { + let result = "1p".to_owned(); + assert_eq!( + result, + div_operation("3p", 2, &CUSTOM_CURRENCY_REGEX, &CURRENCIES, false) + ); + } + + #[test] + fn test_copper_operation() { + let result = "103c".to_owned(); + assert_eq!( + result, + copper_operation("1s 3p", &CUSTOM_CURRENCY_REGEX, &CURRENCIES) + ); + } + + #[test] + fn test_default_operation() { + let result = "1 shilling"; + assert_eq!(result, default_operation("1g", &CURRENCIES, true)); + } + + #[test] + fn test_default_operation_plural_output() { + let result = "2 sterling"; + assert_eq!(result, default_operation("2g", &CURRENCIES, true)); + } +} diff --git a/src/main.rs b/src/main.rs index 9bcaf21..be5f1cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,45 +1,46 @@ #[macro_use] extern crate clap; -#[allow(unused_imports)] -#[macro_use] -extern crate lazy_static; extern crate regex; -extern crate serde; -#[macro_use] -extern crate serde_derive; -extern crate serde_yaml; +extern crate sterling_ops; -mod config; -mod convert; -mod currency; - -use std::collections::HashMap; -use std::io::ErrorKind; use std::process; -use config::ConfigError; -use currency::Currency; use regex::Regex; +use sterling_ops::config; +use sterling_ops::currency::Currency; +use sterling_ops::*; fn main() { let app = clap_app!(sterling => (version: env!("CARGO_PKG_VERSION")) (about: "Converts a given D&D 5e currency value to the Silver Standard.") (@arg CONFIG: -c --config +takes_value "Specify location of config file; defaults to './sterling-conf.yml'.") - (@arg PRINT_FULL: -f --full "Print currencies with full name, rather than with alias.") - (@arg OPTIONAL: -o --optional "Include currencies marked as optional when converting.") + (@arg PRINT_FULL: -f --full "Print currencies with their full name, rather than with their alias") + (@arg OPTIONAL: -o --optional "Include currencies marked as optional when converting") (@arg VALUE: ... "The value to be converted; should be suffixed with the coin's short-hand abbreviation, i.e. p, g, e, s, or c.") (@subcommand add => (about: "Add two currency amounts together; uses the currencies defined in your config file") - (@arg AUGEND: +required "The augend of the addition function; i.e. the left side") - (@arg ADDEND: +required "The addend of the addition function; i.e. the right side") - (@arg PRINT_FULL: -f --full "Print currencies with full name, rather than with alias.") + (@arg AUGEND: +required "The augend of the addition function") + (@arg ADDEND: +required "The addend of the addition function") ) (@subcommand sub => (about: "Subtract two currency amounts from one another; uses the currencies defined in your config file") - (@arg MINUEND: +required "The minuend of the subtraction function; i.e. the left side") - (@arg SUBTRAHEND: +required "The subtrahend of the subtraction function; i.e. the right side") - (@arg PRINT_FULL: -f --full "Print currencies with full name, rather than with alias.") + (@arg MINUEND: +required "The minuend of the subtraction function") + (@arg SUBTRAHEND: +required "The subtrahend of the subtraction function") + ) + (@subcommand mul => + (about: "Multiply a scalar multiplicand by a currency amount; uses the currencies defined in your config file") + (@arg MULTIPLIER: +required "The scalar multiplier of the multiplication function") + (@arg MULTIPLICAND: +required ... "The currency values to be multiplied") + ) + (@subcommand div => + (about: "Divide a currency amount by some scalar divisor; uses the currencies defined in your config file") + (@arg DIVISOR: +required "The scalar divisor of the division function") + (@arg DIVIDEND: +required ... "The currency values to be divided") + ) + (@subcommand copper => + (about: "Calculate the copper value of a custom currency") + (@arg VALUE: +required ... "The custom currency value") ) ); @@ -50,24 +51,22 @@ fn main() { }); let currencies: Vec = - match parse_currency_config(config_result, matches.value_of("CONFIG")) { + match config::parse_currency_config(config_result, matches.value_of("CONFIG")) { Ok(currencies) => currencies - .iter() + .into_iter() .filter(|c| { - let has_add_subcommand = match matches.subcommand_matches("add") { + let is_sub_command = match matches.subcommand_name() { Some(_) => true, None => false, }; - if has_add_subcommand { + if is_sub_command { true - } else if !matches.is_present("OPTIONAL") { - !c.is_optional() } else { - true + (!matches.is_present("OPTIONAL") && !c.is_optional()) + || matches.is_present("OPTIONAL") } }) - .cloned() .collect(), Err(error) => { eprintln!("{}", error); @@ -75,117 +74,81 @@ fn main() { } }; - if let Some(matches) = matches.subcommand_matches("add") { - let (lhs, rhs) = get_copper_value( + let custom_currency_regex: Regex = Regex::new(&format!( + "(\\d+)([{}])", + currencies + .iter() + .map(|c| c.alias.clone()) + .fold(String::new(), |group, a| group + &a) + )).unwrap(); + + let operation_result = match matches.subcommand() { + ("add", Some(command)) => add_operation( + command.value_of("AUGEND").unwrap(), + command.value_of("ADDEND").unwrap(), + &custom_currency_regex, ¤cies, - matches.value_of("AUGEND").unwrap(), - matches.value_of("ADDEND").unwrap(), - ); - - let converted_currencies = convert::convert_currencies(lhs + rhs, currencies); - let display_strings: Vec = - create_display_strings(converted_currencies, matches.is_present("PRINT_FULL")); - - println!("{}", (&display_strings).join(", ")); - } else if let Some(matches) = matches.subcommand_matches("sub") { - let (lhs, rhs) = get_copper_value( + matches.is_present("PRINT_FULL"), + ), + ("sub", Some(command)) => sub_operation( + command.value_of("MINUEND").unwrap(), + command.value_of("SUBTRAHEND").unwrap(), + &custom_currency_regex, ¤cies, - matches.value_of("MINUEND").unwrap(), - matches.value_of("SUBTRAHEND").unwrap(), - ); - - let difference = if lhs > rhs { lhs - rhs } else { rhs - lhs }; - - let converted_currencies = convert::convert_currencies(difference, currencies); - let display_strings: Vec = - create_display_strings(converted_currencies, matches.is_present("PRINT_FULL")); - - println!("{}", (&display_strings).join(", ")); - } else if let Some(values) = matches.values_of("VALUE") { - let coins: Vec<&str> = values.collect(); - let total_copper_value = match convert::calculate_total_copper_value(coins) { - Ok(total_copper_value) => total_copper_value, - Err(err) => { - eprintln!("{}", err); + matches.is_present("PRINT_FULL"), + ), + ("mul", Some(command)) => mul_operation( + &command + .values_of("MULTIPLICAND") + .unwrap() + .collect::>() + .join(" "), + command + .value_of("MULTIPLIER") + .unwrap() + .parse::() + .unwrap(), + &custom_currency_regex, + ¤cies, + matches.is_present("PRINT_FULL"), + ), + ("div", Some(command)) => div_operation( + &command + .values_of("DIVIDEND") + .unwrap() + .collect::>() + .join(" "), + command + .value_of("DIVISOR") + .unwrap() + .parse::() + .unwrap(), + &custom_currency_regex, + ¤cies, + matches.is_present("PRINT_FULL"), + ), + ("copper", Some(command)) => copper_operation( + &command + .values_of("VALUE") + .unwrap() + .collect::>() + .join(" "), + &custom_currency_regex, + ¤cies, + ), + _ => { + if let Some(values) = matches.values_of("VALUE") { + default_operation( + &values.collect::>().join(" "), + ¤cies, + matches.is_present("PRINT_FULL"), + ) + } else { + eprintln!("Sterling Error: please enter at least one value; should be suffixed with the coin's short-hand abbreviation, i.e. p, g, e, s, or c."); process::exit(1); } - }; + } + }; - let converted_currencies = convert::convert_currencies(total_copper_value, currencies); - let display_strings: Vec = - create_display_strings(converted_currencies, matches.is_present("PRINT_FULL")); - - println!("{}", (&display_strings).join(", ")); - } else { - eprintln!("Please enter at least one value; should be suffixed with the coin's short-hand abbreviation, i.e. p, g, e, s, or c."); - process::exit(1); - } -} - -fn parse_currency_config( - config_result: Result, ConfigError>, - config_file_path: Option<&str>, -) -> Result, String> { - match config_result { - Ok(values) => Ok(values), - Err(error) => match error.kind { - ErrorKind::NotFound => { - if let Some(file_path) = config_file_path { - Err(format!( - "Sterling Error: Can't find configuration file: \"{}\"", - &file_path - )) - } else { - Ok(config::default_config()) - } - } - _ => Err(format!("Sterling Error: {}", error)), - }, - } -} - -fn create_display_strings(converted_currencies: Vec, is_print_full: bool) -> Vec { - converted_currencies - .iter() - .map(|c| { - if is_print_full { - c.full_display() - } else { - c.alias_display() - } - }) - .collect() -} - -fn get_copper_value(currencies: &[Currency], lhs: &str, rhs: &str) -> (usize, usize) { - let mut rates: HashMap = HashMap::with_capacity(currencies.len()); - for currency in currencies { - rates.insert(currency.alias.clone(), currency.rate); - } - - let aliases = currencies - .iter() - .cloned() - .map(|c| c.alias) - .fold(String::new(), |group, a| group + &a); - - let regex: Regex = Regex::new(&format!("(\\d+)([{}])", aliases)).unwrap(); - - let left_hand_side: usize = regex.captures_iter(lhs).fold(0, |sum, cap| { - let value: usize = cap[1].parse().unwrap(); - let rate: usize = *rates.get(&cap[2]).unwrap(); - let product = value * rate; - - sum + product - }); - - let right_hand_side: usize = regex.captures_iter(rhs).fold(0, |sum, cap| { - let value: usize = cap[1].parse().unwrap(); - let rate: usize = *rates.get(&cap[2]).unwrap(); - let product = value * rate; - - sum + product - }); - - (left_hand_side, right_hand_side) + println!("{}", operation_result); }