diff --git a/.gitignore b/.gitignore index 0a880fe..ded6399 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,12 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk +### VisualStudioCode ### +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history # End of https://www.gitignore.io/api/rust diff --git a/Cargo.toml b/Cargo.toml index 2afd834..0323977 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sterling" -version = "0.1.0" +version = "0.2.0" description = "Converts a given D&D 5e currency value to the Silver Standard." authors = ["Zachary Dziura "] readme = "README.md" @@ -12,6 +12,10 @@ keywords = ["dnd", "coins", "converter", "currency", "5e"] clap = "2.31.1" lazy_static = "1.0.0" regex = "0.2" +serde = "1.0" +serde_derive = "1.0" +serde_yaml = "0.7" [profile.release] -lto = true \ No newline at end of file +lto = true +panic = "abort" \ No newline at end of file diff --git a/README.md b/README.md index c9362ee..9bc37a3 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,20 @@ and [I make Silver Standard for 5th Edition (Spreadsheets.)](https://www.reddit. ## Usage ``` -sterling [VALUE]... +USAGE: + sterling.exe [FLAGS] [OPTIONS] [VALUE]... FLAGS: + -f, --full Print currencies with full name, rather than with alias. -h, --help Prints help information -V, --version Prints version information +OPTIONS: + -c, --config Specify location of config file; defaults to './sterling-conf.yml'. + ARGS: ... The value to be converted; should be suffixed with the coin's short-hand - abbreviation, i.e. p, g, e, s, or c. Defaults coin type to 'g'. + abbreviation, i.e. p, g, e, s, or c. ``` ## Examples @@ -25,16 +30,52 @@ ARGS: sterling 100p // 10g // Convert one hundred platinum, fifty gold coins: -sterling 100p 50g // 10g 5s +sterling 100p 50g // 10g, 5s -// Convert fifteen thousand copper coins: -sterling 15000c // 1g 50s +// Convert fifteen thousand copper coins, printing the full names of the coins: +sterling -f 15000c // 1 gold, 50 silver // Convert one platinum, thirty-six gold, twelve electrum, eighty-two silver, and four hundred -// sixty-nine copper coins -sterling 1p 36g 12e 82s 469c // 64s 89c +// sixty-nine copper coins, printing the full names of the coins +sterling --full 1p 36g 12e 82s 469c // 64 silver, 89 copper + +// Convert one platinum, thirty-six gold, twelve electrum, eighty-two silver, and four hundred +// sixty-nine copper coins, printing the full names of the coins, using the custom config file +// detailed below. +sterling --full -c "~/Documents/D&D/sterling-conf.yml" 1p 36g 12e 82s 469c // 27 sterling, 9 farthing ``` +## Custom Currencies + +`sterling` allows for user-defined currencies, with their own names and conversion rates. By +default, `sterling` will look at a file within the current directory called `sterling-conf.yml`, or +in whatever location as supplied by the `-c` flag. Below is an example `sterling-conf.yml` file, +showing the actual currencies that I use within my own campaign! + +``` +- + name: "florin" + rate: 8640 + alias: "F" +- + name: "sterling" + rate: 240 + alias: "s" +- + name: "penny" + rate: 12 + alias: "p" + plural: "pence" +- + name: "farthing" + rate: 1 + alias: "f" +``` + +Please note that the `rate` value is defined as the number of copper coins that goes into one of +that particular currency. In the example above, twelve copper coins goes into one "penny", and +two-hundred forty copper coins goes into one "sterling". + ## Abstract Items and expenses are, by default, assigned arbitrary currency values within the official D&D 5th @@ -46,7 +87,7 @@ campaign aught to treat gold similarly! ## Explanation -The basis of the Silver Standard treats 1 gold coin from the official D&D 5e source books as 1 +The basis of the Silver Standard treats one gold coin from the official D&D 5e source books as one silver coin, and that there are one hundred of a given coin to every one of the next highest valued coin. That's all. Thus, one hundred fifty copper coins equals one silver and fifty copper coins, while a suit of heavy plate armor equals fifteen gold coins, rather than fifteen hundred. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c25ace7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,62 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::convert::From; +use std::fs::File; +use std::io::{self, BufReader, ErrorKind}; + +use serde_yaml; + +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)); + + Ok(configs) +} + +pub fn default_config() -> Vec { + vec![ + Currency::new("platinum", 1000000, "p", None), + Currency::new("gold", 10000, "g", None), + Currency::new("silver", 100, "s", None), + Currency::new("copper", 1, "c", None), + ] +} + +#[derive(Debug)] +pub struct ConfigError { + desc: String, + pub kind: ErrorKind, +} + +impl Display for ConfigError { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Sterling Error: {}", self.desc) + } +} + +impl Error for ConfigError { + fn description(&self) -> &str { + &self.desc + } +} + +impl From for ConfigError { + fn from(error: io::Error) -> Self { + ConfigError { + desc: error.description().to_owned(), + kind: error.kind(), + } + } +} + +impl From for ConfigError { + fn from(error: serde_yaml::Error) -> Self { + ConfigError { + desc: error.description().to_owned(), + kind: ErrorKind::Other, + } + } +} diff --git a/src/currency.rs b/src/currency.rs new file mode 100644 index 0000000..93b131e --- /dev/null +++ b/src/currency.rs @@ -0,0 +1,73 @@ +use std::cmp::Ordering; + +#[derive(Clone, Debug, Eq, Serialize, Deserialize)] +pub struct Currency { + pub name: String, + pub rate: usize, + pub value: Option, + pub alias: String, + pub plural: Option, +} + +impl Currency { + pub fn new(name: &str, rate: usize, alias: &str, plural: Option) -> Currency { + Currency { + name: name.to_owned(), + rate, + value: None, + alias: alias.to_owned(), + plural, + } + } + + 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(), + } + } + + 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 Display for Currency { +// fn +// } + +impl Ord for Currency { + fn cmp(&self, other: &Currency) -> Ordering { + self.value.cmp(&other.value) + } +} + +impl PartialOrd for Currency { + fn partial_cmp(&self, other: &Currency) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for Currency { + fn eq(&self, other: &Currency) -> bool { + self.value == other.value + } +} diff --git a/src/main.rs b/src/main.rs index d666f38..e1492ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,50 +1,87 @@ #[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; -use std::fmt; -use std::ops::Add; +mod config; +mod currency; + +use std::io::ErrorKind; use std::process; +use config::ConfigError; +use currency::Currency; use regex::Regex; fn main() { - lazy_static! { - static ref RE: Regex = Regex::new(r"(\d+)([cegps])?").unwrap(); - } - let app = clap_app!(sterling => - (version: "0.1.0") + (version: "0.2.0") (about: "Converts a given D&D 5e currency value to the Silver Standard.") - (@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. Defaults coin type to 'g'.") + (@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 VALUE: ... "The value to be converted; should be suffixed with the coin's short-hand abbreviation, i.e. p, g, e, s, or c.") ); - if let Some(values) = app.get_matches().values_of("VALUE") { + let matches = app.get_matches(); + let config_result = config::load_config(match matches.value_of("CONFIG") { + Some(file) => file, + None => "./sterling-conf.yml", + }); + + let currencies = match parse_currency_config(config_result, matches.value_of("CONFIG")) { + Ok(currencies) => currencies, + Err(error) => { + eprintln!("{}", error); + process::exit(1); + } + }; + + if let Some(values) = matches.values_of("VALUE") { let coins: Vec<&str> = values.collect(); + let total_copper_value = match calculate_total_copper_value(coins) { + Ok(total_copper_value) => total_copper_value, + Err(err) => { + eprintln!("{}", err); + process::exit(1); + } + }; - let total_copper_value: usize = coins - .iter() - .map(|coin| { - if let Some(captures) = RE.captures(coin) { - let amount: usize = captures.get(1).unwrap().as_str().parse().unwrap(); - let denomination = captures.get(2).map_or("g", |d| d.as_str()); - - convert_to_copper(amount, denomination) - } else { - panic!("Error: Invalid coin value \"{}\"", coin); - } - }) - .fold(0 as usize, |total, value| total + value); + let converted_currencies = convert_currencies(total_copper_value, currencies); + let display_strings: Vec = + create_display_strings(converted_currencies, matches.is_present("PRINT_FULL")); - println!("{}", exchange_copper(total_copper_value)); + println!("{}", (&display_strings).join(", ")); } else { - println!("Please enter at least one value; should be suffixed with the coin's short-hand abbreviation, i.e. p, g, e, s, or c. Defaults coin type to 'g'."); + 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 convert_to_copper(amount: usize, coin_denomination: &str) -> usize { match coin_denomination { "p" => amount * 1000, @@ -56,65 +93,157 @@ fn convert_to_copper(amount: usize, coin_denomination: &str) -> usize { } } -fn exchange_copper(copper: usize) -> CurrencyValue { - CurrencyValue { - platinum: copper / 1000000, - gold: (copper % 1000000) / 10000, - silver: ((copper % 1000000) % 10000) / 100, - copper: ((copper % 1000000) % 10000) % 100, - } -} - -#[derive(Debug)] -struct CurrencyValue { - platinum: usize, - gold: usize, - silver: usize, - copper: usize, -} - -impl fmt::Display for CurrencyValue { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let &CurrencyValue { - platinum, - gold, - silver, - copper, - } = self; - - let mut output = String::new(); - - if platinum > 0 { - output = output + &format!("{}p ", platinum); - } - - if gold > 0 { - output = output + &format!("{}g ", gold); - } - - if silver > 0 { - output = output + &format!("{}s ", silver); - } - - if copper > 0 { - output = output + &format!("{}c", copper); - } else if output.is_empty() { - output.push_str("0cp"); - } - - write!(f, "{}", output) - } -} - -impl Add for CurrencyValue { - type Output = CurrencyValue; - - fn add(self, other: CurrencyValue) -> CurrencyValue { - CurrencyValue { - platinum: self.platinum + other.platinum, - gold: self.gold + other.gold, - silver: self.silver + other.silver, - copper: self.copper + other.copper, +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)) +} + +fn exchange(copper: usize, mut currencies: Vec) -> Vec { + let mut val = copper; + currencies + .iter_mut() + .map(|currency| { + let value = val / currency.rate; + val = val % currency.rate; + + currency.with_value(value) + }) + .collect() +} + +fn convert_currencies(copper_value: usize, currencies: Vec) -> Vec { + exchange(copper_value, currencies) + .iter() + .filter(|c| (*c).value.unwrap_or(0) > 0) + .cloned() + .collect() +} + +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() +} + +#[cfg(test)] +mod tests { + use super::*; + use currency::Currency; + + lazy_static! { + static ref STANDARD_CURRENCIES: [Currency; 4] = [ + Currency::new("platinum", 1000000, "p", None), + Currency::new("gold", 10000, "g", None), + Currency::new("silver", 100, "s", None), + Currency::new("copper", 1, "c", 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")); + } + + #[test] + fn test_calculate_total_copper_value() { + let values = vec!["1p", "1g", "1e", "1s", "1c"]; + assert_eq!(1161, calculate_total_copper_value(values).unwrap()); + } + + #[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).with_value(0), + Currency::new("gold", 10000, "g", None).with_value(0), + Currency::new("silver", 100, "s", None).with_value(0), + Currency::new("copper", 1, "c", 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).with_value(0), + Currency::new("gold", 10000, "g", None).with_value(0), + Currency::new("silver", 100, "s", None).with_value(1), + Currency::new("copper", 1, "c", 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).with_value(0), + Currency::new("gold", 10000, "g", None).with_value(1), + Currency::new("silver", 100, "s", None).with_value(0), + Currency::new("copper", 1, "c", 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).with_value(1), + Currency::new("gold", 10000, "g", None).with_value(0), + Currency::new("silver", 100, "s", None).with_value(0), + Currency::new("copper", 1, "c", None).with_value(0), + ]; + + assert_eq!(currencies, exchange(1000000, STANDARD_CURRENCIES.to_vec())); + } }