From 424dc64357ea91017eb966c12eec05d89a725cb8 Mon Sep 17 00:00:00 2001 From: Michael Freeborn <31806808+mfreeborn@users.noreply.github.com> Date: Wed, 11 Mar 2020 23:28:18 +0000 Subject: [PATCH] implement some proper error handling * implement error handling for Coordinates struct * complete rough error handling for config * update reasdme and bump version --- Cargo.lock | 11 --- Cargo.toml | 3 +- README.md | 16 ++-- benches/benches.rs | 4 +- src/bin/heliocron.rs | 24 +++-- src/config.rs | 59 +++++++----- src/enums.rs | 23 ++--- src/errors.rs | 67 +++++++++++++ src/lib.rs | 1 + src/parsers.rs | 68 ++++++------- src/report.rs | 30 +++--- src/structs.rs | 223 ++++++++++++++++++++++++++++++++++++++----- 12 files changed, 388 insertions(+), 141 deletions(-) create mode 100644 src/errors.rs diff --git a/Cargo.lock b/Cargo.lock index b128876..e9fba2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,16 +330,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "guerrilla" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a7ac0c539e590efeeed11427232e6523f9fbcc4130d911ec4e6135c45cca802" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "heck" version = "0.3.1" @@ -357,7 +347,6 @@ dependencies = [ "chrono", "criterion", "dirs", - "guerrilla", "predicates", "serde", "structopt", diff --git a/Cargo.toml b/Cargo.toml index b09a148..5de0b8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "heliocron" -version = "0.2.2" +version = "0.3.0" authors = ["Michael Freeborn "] description = """ Heliocron is a command line application written in Rust capable of delaying execution of other @@ -28,7 +28,6 @@ toml = "0.5" [dev-dependencies] assert_cmd = "0.12" criterion = "0.3" -guerrilla = "0.1" predicates = "1" [profile.release] diff --git a/README.md b/README.md index 7aa7148..177ae49 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ $ cargo install heliocron . . $ heliocron --version -heliocron 0.1.3 +heliocron 0.3.0 ``` #### 3. Build from source @@ -22,7 +22,7 @@ $ git clone https://github.com/mfreeborn/heliocron $ cd heliocron $ cargo build --release $ ./target/release/heliocron --version -heliocron 0.1.3 +heliocron 0.3.0 ``` ## Usage Examples @@ -45,8 +45,8 @@ Ever wondered what time sunrise is in Edinburgh on 7th May 2065? $ heliocron -d "7 May 2065" -f "%e %B %Y" -l 55.9533N -o 3.1883W report LOCATION -------- -Latitude: 55.9533 -Longitude: -3.1883 +Latitude: 55.9533N +Longitude: 3.1883W DATE ---- @@ -72,8 +72,8 @@ Now, using Heliocron without providing specific coordinates will yield the follo $ heliocron -d 2020-03-08 report LOCATION -------- -Latitude: 51.5014 -Longitude: -0.1419 +Latitude: 51.5014N +Longitude: 0.1419W DATE ---- @@ -92,8 +92,8 @@ Arguments passed in via the command line will override those set in the configur $ heliocron -d 2020-03-08 -l 51.4839N -o 0.6044W report LOCATION -------- -Latitude: 51.4839 -Longitude: -0.6044 +Latitude: 51.4839N +Longitude: 0.6044W DATE ---- diff --git a/benches/benches.rs b/benches/benches.rs index 176ac7d..c0a9b84 100644 --- a/benches/benches.rs +++ b/benches/benches.rs @@ -11,8 +11,8 @@ fn criterion_benchmark(c: &mut Criterion) { let date = FixedOffset::east(0).ymd(2020, 2, 25).and_hms(12, 0, 0); let coordinates = structs::Coordinates { - latitude: 51.0, - longitude: 4.0, + latitude: structs::Latitude { value: 51.0 }, + longitude: structs::Longitude { value: 4.0 }, }; c.bench_function("run_report", |b| b.iter(|| run_report(date, coordinates))); diff --git a/src/bin/heliocron.rs b/src/bin/heliocron.rs index a0afef4..9a58475 100644 --- a/src/bin/heliocron.rs +++ b/src/bin/heliocron.rs @@ -2,7 +2,7 @@ use std::process; use chrono::{Duration, FixedOffset, Local, TimeZone}; -use heliocron::{config, enums, report, utils}; +use heliocron::{config, enums, errors, report, utils}; fn wait(offset: Duration, report: report::SolarReport, event: enums::Event) { let event_time = match event { @@ -20,18 +20,26 @@ fn wait(offset: Duration, report: report::SolarReport, event: enums::Event) { utils::sleep(duration_to_sleep, sleep_until); } -fn main() { - let config = config::get_config(); +fn run_heliocron() -> Result<(), errors::HeliocronError> { + let config = config::get_config()?; let report = report::SolarReport::new(config.date, config.coordinates); match config.subcommand { Some(config::Subcommand::Report {}) => println!("{}", report), - Some(config::Subcommand::Wait { offset, event }) => wait(offset, report, event), + Some(config::Subcommand::Wait { offset, event }) => wait(offset?, report, event?), // will never match None as this is caught earlier by StructOpt - None => { - println!("No subcommand provided!"); - process::exit(1) - } + None => println!("No subcommand provided!"), } + Ok(()) +} + +fn main() { + process::exit(match run_heliocron() { + Ok(_) => 0, + Err(err) => { + eprintln!("{}", err); + 1 + } + }); } diff --git a/src/config.rs b/src/config.rs index 7a1acf4..8537886 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,9 +5,14 @@ use dirs; use serde::Deserialize; use structopt::clap::AppSettings; use structopt::StructOpt; -use toml; -use super::{enums, parsers, structs}; +use super::{ + enums, + errors::{ConfigErrorKind, HeliocronError}, + parsers, structs, +}; + +type Result = std::result::Result; #[derive(Debug, StructOpt)] #[structopt( @@ -50,7 +55,7 @@ pub enum Subcommand { default_value = "00:00:00", parse(from_str=parsers::parse_offset), )] - offset: Duration, + offset: Result, #[structopt( help = "Choose one of {sunrise | sunset} from which to base your delay.", @@ -59,7 +64,7 @@ pub enum Subcommand { parse(from_str=parsers::parse_event), possible_values = &["sunrise", "sunset"] )] - event: enums::Event, + event: Result, }, } @@ -89,7 +94,7 @@ impl TomlConfig { } } - fn from_toml(config: Result) -> TomlConfig { + fn from_toml(config: std::result::Result) -> TomlConfig { match config { Ok(conf) => conf, _ => TomlConfig::new(), @@ -106,42 +111,42 @@ pub struct Config { } impl Config { - fn merge_toml(mut self, toml_config: TomlConfig) -> Self { + fn merge_toml(mut self, toml_config: TomlConfig) -> Result { if let (Some(latitude), Some(longitude)) = (toml_config.latitude, toml_config.longitude) { - self.coordinates = structs::Coordinates::from_decimal_degrees(&latitude, &longitude) + self.coordinates = structs::Coordinates::from_decimal_degrees(&latitude, &longitude)? } - self + Ok(self) } - fn merge_cli_args(mut self, cli_args: Cli) -> Self { + fn merge_cli_args(mut self, cli_args: Cli) -> Result { // merge in location if set. Structopt requires either both or neither of lat and long to be set if let (Some(latitude), Some(longitude)) = (cli_args.latitude, cli_args.longitude) { - self.coordinates = structs::Coordinates::from_decimal_degrees(&latitude, &longitude) + self.coordinates = structs::Coordinates::from_decimal_degrees(&latitude, &longitude)? } // set the date let date_args = cli_args.date_args; if let Some(date) = date_args.date { self.date = parsers::parse_date( - Some(&date), + &date, &date_args.date_format, date_args.time_zone.as_deref(), - ); + )?; } // set the subcommand to execute self.subcommand = Some(cli_args.subcommand); - self + Ok(self) } } -pub fn get_config() -> Config { +pub fn get_config() -> Result { // master function for collecting all config variables and returning a single runtime configuration // 0. Set up default config let default_config = Config { - coordinates: structs::Coordinates::from_decimal_degrees("51.4769N", "0.0005W"), + coordinates: structs::Coordinates::from_decimal_degrees("51.4769N", "0.0005W")?, date: Local::today() .and_hms(12, 0, 0) .with_timezone(&FixedOffset::from_offset(Local::today().offset())), @@ -149,24 +154,28 @@ pub fn get_config() -> Config { event: None, }; - // 1. Overwrite defaults with config from ~/.config/heliocron.toml + // 1. Overwrite defaults with config from ~/.config/heliocron.toml if present let path = dirs::config_dir() - .unwrap() + .unwrap() // this shouldn't ever really be None? .join(Path::new("heliocron.toml")); let file = fs::read_to_string(path); let config: Config = match file { - Ok(f) => default_config.merge_toml(TomlConfig::from_toml(toml::from_str(&f))), - // any problems with the config file and we just continue on with the default configuration - _ => default_config, - }; - - // 2. Add/overwrite any currently set config from CLI arguments + Ok(f) => match default_config.merge_toml(TomlConfig::from_toml(toml::from_str(&f))) { + Ok(merged_config) => Ok(merged_config), + // any errors parsing the .toml raise an error + Err(_) => Err(HeliocronError::Config(ConfigErrorKind::InvalidTomlFile)), + }, + // any problems opening the .toml file and we just continue on with the default configuration + Err(_) => Ok(default_config), + }?; + + // 2. Overwrite any currently set config with CLI arguments let cli_args = Cli::from_args(); - let config = config.merge_cli_args(cli_args); + let config = config.merge_cli_args(cli_args)?; - config + Ok(config) } diff --git a/src/enums.rs b/src/enums.rs index 5c87180..1e31449 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -1,4 +1,8 @@ -use std::str::FromStr; +use std::result; + +use super::errors::{ConfigErrorKind, HeliocronError}; + +type Result = result::Result; #[derive(Debug, PartialEq)] pub enum Event { @@ -6,16 +10,13 @@ pub enum Event { Sunset, } -impl FromStr for Event { - type Err = (); - - fn from_str(s: &str) -> Result { - let s: &str = &s.trim().to_lowercase(); - - match s { - "sunrise" => Ok(Self::Sunrise), - "sunset" => Ok(Self::Sunset), - _ => Err(()), +impl Event { + pub fn new(event: &str) -> Result { + let event = event.trim().to_lowercase(); + match event.as_str() { + "sunrise" => Ok(Event::Sunrise), + "sunset" => Ok(Event::Sunset), + _ => Err(HeliocronError::Config(ConfigErrorKind::InvalidEvent)), } } } diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..fb80140 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,67 @@ +use std::error; + +use chrono; + +#[derive(Debug)] +pub enum HeliocronError { + Config(ConfigErrorKind), +} + +#[derive(Debug)] +pub enum ConfigErrorKind { + InvalidCoordindates(&'static str), + InvalidTomlFile, + ParseDate, + InvalidEvent, +} + +impl ConfigErrorKind { + fn as_str(&self) -> &str { + match *self { + ConfigErrorKind::InvalidCoordindates(msg) => msg, + ConfigErrorKind::InvalidTomlFile => { + "Error parsing .toml file. Ensure that it is of the correct format." + } + ConfigErrorKind::ParseDate => { + "Error parsing date. Ensure the date and timezone formats are correct." + } + ConfigErrorKind::InvalidEvent => { + "Error parsing event. Expected one of {'sunrise' | 'sunset'}" + } + } + } +} + +impl std::fmt::Display for HeliocronError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + HeliocronError::Config(ref err) => write!( + f, + "Config error: {}", + match err { + ConfigErrorKind::InvalidCoordindates(msg) => + format!("Invalid coordinates - {}", msg), + ConfigErrorKind::InvalidTomlFile => err.as_str().to_string(), + ConfigErrorKind::ParseDate => err.as_str().to_string(), + ConfigErrorKind::InvalidEvent => err.as_str().to_string(), + } + ), + } + } +} + +impl error::Error for HeliocronError { + fn description(&self) -> &str { + match *self { + HeliocronError::Config(ref err) => err.as_str(), + } + } +} + +impl From for HeliocronError { + fn from(err: chrono::ParseError) -> Self { + match err { + _err => HeliocronError::Config(ConfigErrorKind::ParseDate), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1672265..a95efd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod enums; +pub mod errors; pub mod parsers; pub mod report; pub mod structs; diff --git a/src/parsers.rs b/src/parsers.rs index 54680f0..8bcbe70 100644 --- a/src/parsers.rs +++ b/src/parsers.rs @@ -1,24 +1,25 @@ -use std::str::FromStr; +use std::result; use chrono::{DateTime, Duration, FixedOffset, Local, NaiveTime, TimeZone}; -use super::enums::Event; +use super::{ + enums::Event, + errors::{ConfigErrorKind, HeliocronError}, +}; + +type Result = result::Result; pub fn parse_date( - date: Option<&str>, + date: &str, date_fmt: &str, time_zone: Option<&str>, -) -> DateTime { +) -> Result> { // default date format let time_fmt = "%H:%M:%S"; let datetime_fmt = format!("{}T{}", date_fmt, time_fmt); // customisable date // e.g. 2020-02-24 - let date = match date { - Some(d) => d.to_string(), - None => Local::today().format(date_fmt).to_string(), - }; let time = "12:00:00"; let datetime = format!("{}T{}", date, time); @@ -26,8 +27,7 @@ pub fn parse_date( let time_zone = match time_zone { Some(tz) => tz.to_string(), None => Local - .datetime_from_str(&datetime, &datetime_fmt) - .expect("Error parsing date!") + .datetime_from_str(&datetime, &datetime_fmt)? .offset() .to_string(), }; @@ -35,17 +35,16 @@ pub fn parse_date( let datetimetz_fmt = format!("{}%:z", datetime_fmt); println!("{}/n{}", datetimetz, datetimetz_fmt); - DateTime::parse_from_str(&datetimetz, &datetimetz_fmt) - .expect("Error parsing date with time zone!") + let datetime = DateTime::parse_from_str(&datetimetz, &datetimetz_fmt)?; + + Ok(datetime) } -pub fn parse_event(event: &str) -> Event { - Event::from_str(event).expect(&format!( - "Error parsing event. Expected one of {{sunrise | sunset}}, got \"{}\".", - event - )) + +pub fn parse_event(event: &str) -> Result { + Ok(Event::new(event)?) } -pub fn parse_offset(offset: &str) -> Duration { +pub fn parse_offset(offset: &str) -> Result { // offset should either be %H:%M:%S or %H:%M +/- a "-" if negative let (positive, offset): (bool, &str) = match offset.chars().next() { Some('-') => (false, &offset[1..]), @@ -54,20 +53,20 @@ pub fn parse_offset(offset: &str) -> Duration { let offset = match offset { offset if NaiveTime::parse_from_str(offset, "%H:%M:%S").is_ok() => { - NaiveTime::parse_from_str(offset, "%H:%M:%S").unwrap() + Ok(NaiveTime::parse_from_str(offset, "%H:%M:%S")?) } offset if NaiveTime::parse_from_str(offset, "%H:%M").is_ok() => { - NaiveTime::parse_from_str(offset, "%H:%M").unwrap() + Ok(NaiveTime::parse_from_str(offset, "%H:%M")?) } - _ => panic!("Error parsing offset! Expected the format to be one of: %H:%M:%S | %H:%M"), - }; + _ => Err(HeliocronError::Config(ConfigErrorKind::ParseDate)), + }?; let offset = offset.signed_duration_since(NaiveTime::from_hms(0, 0, 0)); if positive { - offset + Ok(offset) } else { - -offset + Ok(-offset) } } @@ -79,11 +78,11 @@ mod tests { fn test_parse_date() { let expected = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); // standard usage, just passing in a date - let result = parse_date(Some("2020-03-25"), "%Y-%m-%d", None); + let result = parse_date("2020-03-25", "%Y-%m-%d", None).unwrap(); assert_eq!(expected, result); // but if you want to use a snazzy format, that is ok, too - let result = parse_date(Some("25 March 2020"), "%d %B %Y", None); + let result = parse_date("25 March 2020", "%d %B %Y", None).unwrap(); assert_eq!(expected, result); // and so is providing a custom timezone @@ -91,27 +90,20 @@ mod tests { .with_timezone(&FixedOffset::east(3600)) .with_hour(12) .unwrap(); - let result = parse_date(Some("25 Mar 2020"), "%d %b %Y", Some("+01:00")); - assert_eq!(expected, result); - - // if no user arguments are passed in, then return the Local date - let expected = Local::today() - .and_hms(12, 0, 0) - .with_timezone(&FixedOffset::from_offset(Local::now().offset())); - let result = parse_date(None, "%Y-%m%-d", None); + let result = parse_date("25 Mar 2020", "%d %b %Y", Some("+01:00")).unwrap(); assert_eq!(expected, result); } #[test] #[should_panic] fn test_parse_date_wrong_format_fails() { - let _result = parse_date(Some("2020-03-25"), "%Y-%m-%Y", None); + let _result = parse_date("2020-03-25", "%Y-%m-%Y", None).unwrap(); } #[test] #[should_panic] fn test_parse_date_wrong_tz_fails() { - let _result = parse_date(Some("2020-03-25"), "%Y-%m-%d", Some("00:00")); + let _result = parse_date("2020-03-25", "%Y-%m-%d", Some("00:00")).unwrap(); } #[test] @@ -125,13 +117,13 @@ mod tests { ]; for (expected, arg) in params.iter() { - assert_eq!(*expected, parse_event(*arg)); + assert_eq!(*expected, parse_event(*arg).unwrap()); } } #[test] #[should_panic] fn test_parse_event_fails() { - let _event = parse_event("sun rise"); + let _event = parse_event("sun rise").unwrap(); } } diff --git a/src/report.rs b/src/report.rs index b6767f0..d749b4a 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,9 +1,11 @@ #[path = "./traits.rs"] mod traits; -use super::structs; -use chrono::{DateTime, Duration, FixedOffset, Local, NaiveTime, Offset, TimeZone, Timelike}; use std::fmt; + +use chrono::{DateTime, Duration, FixedOffset, Local, NaiveTime, Offset, TimeZone, Timelike}; + +use super::{structs, structs::Coordinate}; use traits::DateTimeExt; #[derive(Debug)] @@ -26,7 +28,7 @@ impl Default for SolarReport { sunrise: default_datetime, sunset: default_datetime, date: local_time.with_timezone(&FixedOffset::from_offset(local_time.offset())), - coordinates: structs::Coordinates::from_decimal_degrees("0.0N", "0.0W"), + coordinates: structs::Coordinates::from_decimal_degrees("0.0N", "0.0W").unwrap(), } } } @@ -36,8 +38,8 @@ impl fmt::Display for SolarReport { let fmt_str = format!( "LOCATION\n\ --------\n\ - Latitude: {}\n\ - Longitude: {}\n\n\ + {}\n\ + {}\n\n\ DATE\n\ ----\n\ {}\n\n\ @@ -173,7 +175,7 @@ impl SolarReport { .acos()) .to_degrees(); - let solar_noon = (720.0 - 4.0 * self.coordinates.longitude - equation_of_time + let solar_noon = (720.0 - 4.0 * self.coordinates.longitude.value - equation_of_time + time_zone * 60.0) / 1440.0; @@ -193,8 +195,8 @@ mod tests { fn test_solar_report_new() { let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); let coordinates = structs::Coordinates { - latitude: 0.0, - longitude: 0.0, + latitude: structs::Latitude { value: 0.0 }, + longitude: structs::Longitude { value: 0.0 }, }; // Default trait should handle the rest let _new_report = SolarReport::new(date, coordinates); @@ -203,7 +205,8 @@ mod tests { fn test_sunrise_sunset() { // validated against NOAA calculations https://www.esrl.noaa.gov/gmd/grad/solcalc/calcdetails.html let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); - let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W"); + let coordinates = + structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W").unwrap(); let mut report = SolarReport { date, coordinates, @@ -217,7 +220,8 @@ mod tests { assert_eq!("18:36:59", report.sunset.time().to_string()); let date = DateTime::parse_from_rfc3339("2020-03-30T12:00:00+01:00").unwrap(); - let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W"); + let coordinates = + structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W").unwrap(); let mut report = SolarReport { date, coordinates, @@ -231,7 +235,7 @@ mod tests { assert_eq!("19:47:03", report.sunset.time().to_string()); let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); - let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "174.0W"); + let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "174.0W").unwrap(); let mut report = SolarReport { date, coordinates, @@ -251,7 +255,7 @@ mod tests { // occur either the following or previous day. This results in a day fraction which is either negative // or >= 1 let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); - let coordinates = structs::Coordinates::from_decimal_degrees("0.0N", "0.0W"); + let coordinates = structs::Coordinates::from_decimal_degrees("0.0N", "0.0W").unwrap(); let report = SolarReport::new(date, coordinates); let params = [ @@ -267,7 +271,7 @@ mod tests { #[test] fn test_day_fraction_to_time() { let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); - let coordinates = structs::Coordinates::from_decimal_degrees("0.0N", "0.0W"); + let coordinates = structs::Coordinates::from_decimal_degrees("0.0N", "0.0W").unwrap(); let report = SolarReport::new(date, coordinates); let params = [ ("2020-03-25 00:00:00 +00:00", 0.0), diff --git a/src/structs.rs b/src/structs.rs index f239ce5..d5c0134 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,33 +1,181 @@ +use std::{fmt, result}; + use serde::Deserialize; +use super::errors::{ConfigErrorKind, HeliocronError}; + +type Result = result::Result; + +fn invalid_coordinates_error(msg: &'static str) -> HeliocronError { + HeliocronError::Config(ConfigErrorKind::InvalidCoordindates(msg)) +} + #[derive(Debug, Deserialize, Clone, Copy)] pub struct Coordinates { - pub latitude: f64, - pub longitude: f64, + pub latitude: Latitude, + pub longitude: Longitude, } -impl Coordinates { - pub fn from_decimal_degrees(latitude: &str, longitude: &str) -> Coordinates { - Coordinates { - latitude: Coordinates::parse_coordinate(latitude), - longitude: Coordinates::parse_coordinate(longitude), +#[derive(Debug, Deserialize, Clone, Copy)] +pub struct Latitude { + pub value: f64, +} + +#[derive(Debug, Deserialize, Clone, Copy)] +pub struct Longitude { + pub value: f64, +} + +impl fmt::Display for Latitude { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let compass_direction = match self.value.is_sign_positive() { + true => "N", + false => "S", + }; + write!(f, "Latitude: {:.4}{}", self.value.abs(), compass_direction) + } +} + +impl fmt::Display for Longitude { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let compass_direction = match self.value.is_sign_positive() { + true => "E", + false => "W", + }; + write!(f, "Longitude: {:.4}{}", self.value.abs(), compass_direction) + } +} + +pub trait Coordinate: Sized { + fn from_decimal_degrees(coordinate: &str) -> Result; + + fn to_radians(&self) -> f64; + + fn parse_compass_direction(coordinate: &str) -> Result; + fn parse_decimal_degrees(coordinate: &str) -> Result; + fn compass_correction(compass_direction: char) -> Result; +} + +impl Coordinate for Latitude { + fn from_decimal_degrees(latitude: &str) -> Result { + // strictly, the latitude format must be a positive float or integer with an upper or lowercase 'N' or 'S' + + let compass_direction: char = Self::parse_compass_direction(latitude)?; + let decimal_degrees: f64 = Self::parse_decimal_degrees(latitude)?; + let compass_correction: f64 = Self::compass_correction(compass_direction)?; + + Ok(Latitude { + value: decimal_degrees * compass_correction, + }) + } + + fn parse_compass_direction(latitude: &str) -> Result { + match latitude + .to_lowercase() + .chars() + .last() + .ok_or(HeliocronError::Config( + ConfigErrorKind::InvalidCoordindates("No coordinates found."), + ))? { + c if c == 'n' || c == 's' => Ok(c), + _ => Err(invalid_coordinates_error( + "Latitude must end with 'N' or 'S'.", + )), } } - fn parse_coordinate(coordinate: &str) -> f64 { - let compass_direction: &str = &coordinate[coordinate.len() - 1..].to_lowercase(); + fn parse_decimal_degrees(latitude: &str) -> Result { + latitude[..latitude.len() - 1] + .parse() + .map_err(|_| invalid_coordinates_error("Latitude must be a positive value followed by a compass direction ('N' or 'S').")) + .and_then(|n: f64| match n { + n if n.is_sign_positive() => match n { + n if (0.0..=90.0).contains(&n) => Ok(n), + _ => Err(invalid_coordinates_error("Latitude must be a positive value between 0.0 and 90.0")), + }, + _ => Err(invalid_coordinates_error("Latitude must be a positive value between 0.0 and 90.0.")), + }) + } - let compass_correction = match compass_direction { - "n" | "e" => 1.0, - "w" | "s" => -1.0, - _ => panic!("Expected latitude/longitude to end with one of: N, S, E, W"), - }; + fn compass_correction(compass_direction: char) -> Result { + match compass_direction { + 'n' => Ok(1.0), + 's' => Ok(-1.0), + _ => Err(invalid_coordinates_error( + "Latitude must be a positive value followed by a compass direction ('N' or 'S').", + )), + } + } + + fn to_radians(&self) -> f64 { + self.value.to_radians() + } +} + +impl Coordinate for Longitude { + fn from_decimal_degrees(longitude: &str) -> Result { + // strictly, the longitude format must be a positive float or integer with an upper or lowercase 'W' or 'E' - let parsed_coordinate: f64 = coordinate[..coordinate.len() - 1] + let compass_direction: char = Self::parse_compass_direction(longitude)?; + let decimal_degrees: f64 = Self::parse_decimal_degrees(longitude)?; + let compass_correction: f64 = Self::compass_correction(compass_direction)?; + + Ok(Longitude { + value: decimal_degrees * compass_correction, + }) + } + + fn parse_compass_direction(longitude: &str) -> Result { + match longitude + .to_lowercase() + .chars() + .last() + .ok_or(HeliocronError::Config( + ConfigErrorKind::InvalidCoordindates("No coordinates found."), + ))? { + c if c == 'w' || c == 'e' => Ok(c), + _ => Err(invalid_coordinates_error( + "Longitude must end with 'W' or 'E'.", + )), + } + } + + fn parse_decimal_degrees(longitude: &str) -> Result { + longitude[..longitude.len() - 1] .parse() - .expect("Error, expected a float!"); + .map_err(|_| invalid_coordinates_error("Longitude must be a positive value followed by a compass direction ('W' or 'E').")) + .and_then(|n: f64| match n { + n if n.is_sign_positive() => match n { + n if (0.0..=180.0).contains(&n) => Ok(n), + _ => Err(invalid_coordinates_error("Longitude must be a positive value between 0.0 and 180.0")), + }, + _ => Err(invalid_coordinates_error("Longitude must be a positive value between 0.0 and 180.0.")), + }) + } + + fn compass_correction(compass_direction: char) -> Result { + match compass_direction { + 'e' => Ok(1.0), + 'w' => Ok(-1.0), + _ => Err(invalid_coordinates_error( + "Longitude must be a positive value followed by a compass direction ('W' or 'E').", + )), + } + } + + fn to_radians(&self) -> f64 { + self.value.to_radians() + } +} - parsed_coordinate * compass_correction +impl Coordinates { + pub fn from_decimal_degrees(latitude: &str, longitude: &str) -> Result { + let latitude = Latitude::from_decimal_degrees(latitude)?; + let longitude = Longitude::from_decimal_degrees(longitude)?; + Ok(Coordinates { + latitude, + longitude, + }) } } @@ -35,18 +183,47 @@ impl Coordinates { mod tests { use super::*; #[test] - fn test_parse_coordinates() { + fn tesy() { + let x = Latitude::from_decimal_degrees("15.6w"); + println!("{:?}", x); + } + + #[test] + fn test_parse_latitude() { let params = [ (50.0, "50.0N"), - (50.0, "-50.0S"), + (-50.0, "50.0S"), (-33.9, "33.9S"), - (18.552, "18.552E"), - (-26.02, "26.020W"), - (-26.02, "-26.020E"), + (18.552, "18.552n"), + (-26.02, "26.020s"), + (90.0, "90.0n"), + (0.0, "0.0n"), + ]; + + for (expected, arg) in params.iter() { + assert_eq!( + *expected, + Latitude::from_decimal_degrees(*arg).unwrap().value + ) + } + } + #[test] + fn test_parse_longitude() { + let params = [ + (50.0, "50.0E"), + (-50.0, "50.0W"), + (-33.9, "33.9W"), + (18.552, "18.552e"), + (-26.02, "26.020w"), + (180.0, "180.0e"), + (0.0, "0.0e"), ]; for (expected, arg) in params.iter() { - assert_eq!(*expected, Coordinates::parse_coordinate(*arg)) + assert_eq!( + *expected, + Longitude::from_decimal_degrees(*arg).unwrap().value + ) } } }