diff options
author | Sam Tay <sam.chong.tay@gmail.com> | 2020-06-06 19:55:23 -0700 |
---|---|---|
committer | Sam Tay <sam.chong.tay@gmail.com> | 2020-06-07 02:21:54 -0700 |
commit | 799936479d3e80237c6e6595baade9737e137011 (patch) | |
tree | d74d5bad458d08cfd2f74f125c4c45fd3128d82c | |
parent | 735fd480e43d6037f3ae7890c2795fdf0e431fd7 (diff) |
Propagate errors throughout app
and add stdout styles via crossterm
-rw-r--r-- | Cargo.lock | 102 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | README.md | 3 | ||||
-rw-r--r-- | TODO.md | 4 | ||||
-rw-r--r-- | src/cli.rs | 12 | ||||
-rw-r--r-- | src/config.rs | 32 | ||||
-rw-r--r-- | src/error.rs | 83 | ||||
-rw-r--r-- | src/macros.rs | 15 | ||||
-rw-r--r-- | src/main.rs | 117 | ||||
-rw-r--r-- | src/stackexchange.rs | 105 | ||||
-rw-r--r-- | src/term.rs | 60 |
11 files changed, 429 insertions, 106 deletions
@@ -16,10 +16,10 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.31" +name = "arc-swap" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bb70cc08ec97ca5450e6eba421deeea5f172c0fc61f78b5357b2a8e8be195f" +checksum = "4d25d88fd6b8041580a654f9d0c581a047baee2b3efee13275f2fc392fc75034" [[package]] name = "arrayref" @@ -119,6 +119,15 @@ dependencies = [ ] [[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags", +] + +[[package]] name = "constant_time_eq" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -161,6 +170,31 @@ dependencies = [ ] [[package]] +name = "crossterm" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9851d20b9809e561297ec3ca85d7cba3a57507fe8d01d07ba7b52469e1c89a11" +dependencies = [ + "bitflags", + "crossterm_winapi", + "lazy_static", + "libc", + "mio", + "parking_lot", + "signal-hook", + "winapi 0.3.8", +] + +[[package]] +name = "crossterm_winapi" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "057b7146d02fb50175fd7dbe5158f6097f33d02831f43b4ee8ae4ddf67b68f5c" +dependencies = [ + "winapi 0.3.8", +] + +[[package]] name = "directories" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -473,6 +507,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8dd5a6d5999d9907cda8ed67bbd137d3af8085216c2ac62de5be860bd41f304a" [[package]] +name = "lock_api" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4da24a77a3d8a6d4862d95f72e6fdb9c09a643ecdb402d754004a557f2bec75" +dependencies = [ + "scopeguard", +] + +[[package]] name = "log" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -628,6 +671,30 @@ dependencies = [ ] [[package]] +name = "parking_lot" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a704eb390aafdc107b0e392f56a82b668e3a71366993b5340f5833fd62505e" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d58c7c768d4ba344e3e8d72518ac13e259d7c7ade24167003b8488e10b6740a3" +dependencies = [ + "cfg-if", + "cloudabi", + "libc", + "redox_syscall", + "smallvec", + "winapi 0.3.8", +] + +[[package]] name = "percent-encoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -825,6 +892,12 @@ dependencies = [ ] [[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] name = "security-framework" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -903,6 +976,27 @@ dependencies = [ ] [[package]] +name = "signal-hook" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff2db2112d6c761e12522c65f7768548bd6e8cd23d2a9dae162520626629bd6" +dependencies = [ + "libc", + "mio", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f478ede9f64724c5d173d7bb56099ec3e2d9fc2774aac65d34b8b890405f41" +dependencies = [ + "arc-swap", + "libc", +] + +[[package]] name = "slab" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -918,8 +1012,8 @@ checksum = "c7cb5678e1615754284ec264d9bb5b4c27d2018577fd90ac0ceb578591ed5ee4" name = "so" version = "0.1.0" dependencies = [ - "anyhow", "clap", + "crossterm", "directories", "flate2", "reqwest", @@ -7,7 +7,7 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0" +crossterm = "0.17" clap = "2.33" directories = "2.0" flate2 = "1.0" @@ -11,3 +11,6 @@ run into throttling issues, get a key ``` so --set-api-key <KEY> ``` + +Recall my api key is: `8o9g7WcfwnwbB*Qp4VsGsw((` + @@ -1,8 +1,6 @@ # TODO ### initial release -0. Install sites when file not found -0. Implement --update-sites command 3. Parse markdown (`pulldown_cmark`) 4. Maybe default --validate-sites off (parsing 30k file a big hit) 5. Print to stderr in [style](https://github.com/BurntSushi/termcolor) @@ -20,3 +18,5 @@ 7. App Distribution [cross-platform binaries via travis](https://github.com/rustwasm/wasm-pack/blob/51e6351c28fbd40745719e6d4a7bf26dadd30c85/.travis.yml#L74-L91) also see lobster script in this [repo](https://git.sr.ht/~wezm/lobsters). + +9. Great tui-rs [example app](https://github.com/SoptikHa2/desed/blob/master/src/ui/tui.rs) @@ -2,6 +2,7 @@ use clap::{App, AppSettings, Arg}; use crate::config; use crate::config::Config; +use crate::error::Result; // TODO maybe consts for these keywords? @@ -17,8 +18,8 @@ pub struct Opts { pub config: Config, } -pub fn get_opts() -> Opts { - let config = config::user_config(); +pub fn get_opts() -> Result<Opts> { + let config = config::user_config()?; let limit = &config.limit.to_string(); let matches = App::new("so") .setting(AppSettings::ColoredHelp) @@ -69,20 +70,21 @@ pub fn get_opts() -> Opts { .required_unless_one(&["list-sites", "update-sites"]), ) .get_matches(); - Opts { + + Ok(Opts { list_sites: matches.is_present("list-sites"), update_sites: matches.is_present("update-sites"), query: matches .values_of("query") .map(|q| q.into_iter().collect::<Vec<_>>().join(" ")), config: Config { - // these unwraps are safe b.c. default value + // these unwraps are safe via clap default values & validators limit: matches.value_of("limit").unwrap().parse::<u16>().unwrap(), site: matches.value_of("site").unwrap().to_string(), // TODO if set_api_key passed, pass it here too ..config }, - } + }) } #[cfg(test)] diff --git a/src/config.rs b/src/config.rs index 957a46d..88666d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,8 @@ use serde_yaml; use std::fs; use std::fs::File; +use crate::error::{Error, Result}; + #[derive(Deserialize, Serialize, Debug)] pub struct Config { pub api_key: Option<String>, @@ -22,32 +24,28 @@ impl Default for Config { } /// Get user config (writes default if none found) -pub fn user_config() -> Config { - let project = project_dir(); +pub fn user_config() -> Result<Config> { + let project = project_dir()?; let dir = project.config_dir(); - fs::create_dir_all(&dir).unwrap(); // TODO bubble to main + fs::create_dir_all(&dir).map_err(|_| Error::create_dir(&dir.to_path_buf()))?; let filename = dir.join("config.yml"); match File::open(&filename) { Err(_) => { - let file = File::create(&filename).unwrap(); + let file = File::create(&filename).map_err(|_| Error::create_file(&filename))?; let def = Config::default(); - serde_yaml::to_writer(file, &def).unwrap(); - def + serde_yaml::to_writer(file, &def).map_err(|_| Error::write_file(&filename))?; + Ok(def) } - Ok(file) => serde_yaml::from_reader(file).expect(&format!( - "Local config corrupted; try removing it `rm {}`", - filename.display() - )), + Ok(file) => serde_yaml::from_reader(file).map_err(|_| Error::malformed(&filename)), } } -/// Get project directory; might panic on unexpected OS -pub fn project_dir() -> ProjectDirs { - ProjectDirs::from("io", "Sam Tay", "so").expect( - "Couldn't find - a suitable project directory to store cache and configuration; this - application may not be supported on your operating system.", - ) +/// Get project directory +pub fn project_dir() -> Result<ProjectDirs> { + ProjectDirs::from("io", "Sam Tay", "so").ok_or(Error::os( + "Couldn't find a suitable project directory to store cache and configuration;\n\ + this application may not be supported on your operating system.", + )) } #[cfg(test)] diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b6046af --- /dev/null +++ b/src/error.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; + +pub type Result<T, E = Error> = std::result::Result<T, E>; + +pub struct Error { + #[allow(dead_code)] + pub kind: ErrorKind, + pub error: String, +} +pub enum ErrorKind { + Malformed, + StackExchange, + Permissions, + OperatingSystem, + Panic, + EmptySites, + NoResults, +} + +impl From<&str> for Error { + fn from(err: &str) -> Self { + Error { + kind: ErrorKind::Panic, + error: String::from(err), + } + } +} + +// TODO add others +impl Error { + pub fn malformed(path: &PathBuf) -> Self { + Error { + kind: ErrorKind::Malformed, + error: format!("File {} is malformed; try removing it.", path.display()), + } + } + pub fn se(err: String) -> Self { + Error { + kind: ErrorKind::StackExchange, + error: err, + } + } + pub fn create_dir(path: &PathBuf) -> Self { + Error { + kind: ErrorKind::Permissions, + error: format!( + "Couldn't create directory {}; please check the permissions + on the parent directory", + path.display() + ), + } + } + pub fn create_file(path: &PathBuf) -> Self { + Error { + kind: ErrorKind::Permissions, + error: format!( + "Couldn't create file {}; please check the directory permissions", + path.display() + ), + } + } + pub fn write_file(path: &PathBuf) -> Self { + Error { + kind: ErrorKind::Permissions, + error: format!( + "Couldn't write to file {}; please check its permissions", + path.display() + ), + } + } + pub fn os(err: &str) -> Self { + Error { + kind: ErrorKind::OperatingSystem, + error: String::from(err), + } + } + pub fn no_results() -> Self { + Error { + kind: ErrorKind::NoResults, + error: String::from("Sorry, no answers found for your question. Try another query."), + } + } +} diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..a41443c --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,15 @@ +#[macro_export] +macro_rules! printerr { + ($($arg:tt)*) => ({ + use std::io::{Write, stderr}; + use crossterm::{execute}; + use crossterm::style::{Print, SetForegroundColor, ResetColor, Color}; + execute!( + stderr(), + SetForegroundColor(Color::Red), + Print("✖ ".to_string()), + Print($($arg)*.to_string()), + ResetColor + ).ok(); + }) +} diff --git a/src/main.rs b/src/main.rs index e9f0d99..e48323d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,63 +1,94 @@ mod cli; mod config; +mod error; +mod macros; mod stackexchange; +mod term; use config::Config; +use error::{Error, ErrorKind}; use stackexchange::{LocalStorage, StackExchange}; +use std::io::stderr; +use term::ColoredOutput; fn main() { - // TODO wrap inner function with Result<(), ErrorMessage>, propagate, print to stderr at the top level. - let opts = cli::get_opts(); - let config = opts.config; - let site = &config.site; - let mut ls = LocalStorage::new(); + (|| { + let opts = cli::get_opts()?; + let config = opts.config; + let site = &config.site; + let mut ls = LocalStorage::new()?; - if opts.update_sites { - ls.update_sites(); - } + if opts.update_sites { + ls.update_sites()?; + } - if opts.list_sites { - let sites = ls.sites(); - match sites.into_iter().map(|s| s.api_site_parameter.len()).max() { - Some(max_w) => { - for s in ls.sites() { - println!("{:>w$}: {}", s.api_site_parameter, s.site_url, w = max_w); + if opts.list_sites { + let sites = ls.sites()?; + match sites.into_iter().map(|s| s.api_site_parameter.len()).max() { + Some(max_w) => { + for s in sites { + println!("{:>w$}: {}", s.api_site_parameter, s.site_url, w = max_w); + } + } + None => { + stderr() + .queue_error("The site list is empty. Try running ") + .queue_code_inline("so --update-sites") + .unsafe_flush(); } } - None => { - // TODO stderr - println!("The site list is empty. Try running `so --update-sites`."); + return Ok(()); + } + + match ls.validate_site(site) { + Ok(true) => (), + Ok(false) => { + stderr() + .queue_error(&format!("{} is not a valid StackExchange site.\n\n", site)[..]) + .queue_notice("If you think this is in error, try running\n\n") + .queue_code("so --update-sites\n\n") + .queue_notice_inline("to update the cached site listing. You can also run ") + .queue_code_inline("so --list-sites") + .queue_notice_inline(" to list all available sites.") + .unsafe_flush(); + return Ok(()); } + Err(Error { + kind: ErrorKind::EmptySites, + .. + }) => { + stderr() + .queue_error("The cached site list is empty. This can likely be fixed by\n\n") + .queue_code("so --update-sites\n\n") + .unsafe_flush(); + return Ok(()); + } + Err(e) => return Err(e), } - return; - } - // TODO make this validation optional - if !ls.validate_site(site) { - // TODO tooling for printing to stderr with color, etc. - println!( - "{} is not a valid StackExchange site. If you think this - is in error, try running `so --update-sites` to update - the cached site listing. Run `so --list-sites` for all - available sites.", - site - ); - return; - } + let se = StackExchange::new(Config { + api_key: Some(String::from("8o9g7WcfwnwbB*Qp4VsGsw((")), // TODO remove when releasing + ..config + }); - let se = StackExchange::new(Config { - api_key: Some(String::from("8o9g7WcfwnwbB*Qp4VsGsw((")), // TODO stash this - ..config - }); + if let Some(q) = opts.query { + let que = se.search(&q)?; + let ans = que + .first() + .ok_or(Error::no_results())? + .answers + .first() + .ok_or(Error::from( + "StackExchange returned a question with no answers; this shouldn't be possible!", + ))?; + println!("{}", ans.body); + } - let query = opts.query; - (|| -> Option<_> { - let q = query?; - let que = se.search(&q).unwrap(); // TODO eventually be graceful - let ans = que.first()?.answers.first()?; - println!("{}", ans.body); - Some(()) - })(); + Ok(()) + })() + .unwrap_or_else(|e| match e { + Error { error, .. } => printerr!(error), + }) } #[cfg(test)] diff --git a/src/stackexchange.rs b/src/stackexchange.rs index 1aed7d8..9cd9767 100644 --- a/src/stackexchange.rs +++ b/src/stackexchange.rs @@ -1,4 +1,3 @@ -use anyhow; use flate2::read::GzDecoder; use reqwest::blocking::Client; use reqwest::Url; @@ -9,6 +8,7 @@ use std::fs::File; use std::path::PathBuf; use crate::config::{project_dir, Config}; +use crate::error::{Error, ErrorKind, Result}; /// StackExchange API v2.2 URL const SE_URL: &str = "http://api.stackexchange.com/2.2/"; @@ -81,10 +81,10 @@ impl StackExchange { /// Only fetches questions that have at least one answer. /// TODO async /// TODO parallel requests over multiple sites - pub fn search(&self, q: &str) -> Result<Vec<Question>, anyhow::Error> { + pub fn search(&self, q: &str) -> Result<Vec<Question>> { let resp_body = self .client - .get(stackechange_url("search/advanced")) + .get(stackexchange_url("search/advanced")) .header("Accepts", "application/json") .query(&self.get_default_opts()) .query(&[ @@ -95,9 +95,22 @@ impl StackExchange { ("order", "desc"), ("sort", "relevance"), ]) - .send()?; + .send() + .map_err(|e| { + // TODO explore legit errors such as not connected to internet + Error::se(format!( + "Error encountered while querying StackExchange: {}", + e + )) + })?; + let gz = GzDecoder::new(resp_body); - let wrapper: ResponseWrapper<Question> = serde_json::from_reader(gz)?; + let wrapper: ResponseWrapper<Question> = serde_json::from_reader(gz).map_err(|e| { + Error::se(format!( + "Error decoding questions from the StackExchange API: {}", + e + )) + })?; let qs = wrapper .items .into_iter() @@ -121,69 +134,93 @@ impl StackExchange { } impl LocalStorage { - pub fn new() -> Self { - let project = project_dir(); + pub fn new() -> Result<Self> { + let project = project_dir()?; let dir = project.cache_dir(); - fs::create_dir_all(&dir).unwrap(); // TODO bubble to main - LocalStorage { + fs::create_dir_all(&dir).map_err(|_| Error::create_dir(&dir.to_path_buf()))?; + Ok(LocalStorage { sites: None, filename: dir.join("sites.json"), - } + }) } // TODO make this async, inform user if we are downloading - pub fn sites(&mut self) -> &Vec<Site> { + pub fn sites(&mut self) -> Result<&Vec<Site>> { + // Stop once Option ~ Some or Result ~ Err + if let Some(_) = self.sites { + return Ok(self.sites.as_ref().unwrap()); // safe + } + if let Some(_) = self.fetch_local_sites()? { + return Ok(self.sites.as_ref().unwrap()); // safe + } + self.fetch_remote_sites()?; self.sites .as_ref() - .map(|_| ()) // stop if we already have sites - .or_else(|| self.fetch_local_sites()) // otherwise try local cache - .unwrap_or_else(|| self.fetch_remote_sites()); // otherwise remote fetch - self.sites.as_ref().unwrap() // we will have paniced earlier on failure + .ok_or(Error::from("Code failure in site listing retrieval")) } - pub fn update_sites(&mut self) { - self.fetch_remote_sites(); + pub fn update_sites(&mut self) -> Result<()> { + self.fetch_remote_sites() } - pub fn validate_site(&mut self, site_code: &String) -> bool { - self.sites() + pub fn validate_site(&mut self, site_code: &String) -> Result<bool> { + let sites = self.sites()?; + if sites.is_empty() { + return Err(Error { + kind: ErrorKind::EmptySites, + error: String::from(""), + }); + } + Ok(sites .iter() - .any(|site| site.api_site_parameter == *site_code) + .any(|site| site.api_site_parameter == *site_code)) } - fn fetch_local_sites(&mut self) -> Option<()> { - let file = File::open(&self.filename).ok()?; - self.sites = serde_json::from_reader(file) - .expect("Local cache corrupted; try running `so --update-sites`"); - Some(()) + fn fetch_local_sites(&mut self) -> Result<Option<()>> { + if let Some(file) = File::open(&self.filename).ok() { + self.sites = + serde_json::from_reader(file).map_err(|_| Error::malformed(&self.filename))?; + return Ok(Some(())); + } + Ok(None) } // TODO decide whether or not I should give LocalStorage an api key.. // TODO cool loading animation? - fn fetch_remote_sites(&mut self) { + fn fetch_remote_sites(&mut self) -> Result<()> { let resp_body = Client::new() - .get(stackechange_url("sites")) + .get(stackexchange_url("sites")) .header("Accepts", "application/json") .query(&[ ("pagesize", SE_SITES_PAGESIZE.to_string()), ("page", "1".to_string()), ]) .send() - .unwrap(); // TODO inspect response for errors e.g. throttle + .map_err(|e| { + Error::se(format!( + "Error requesting sites from StackExchange API: {}", + e + )) + })?; let gz = GzDecoder::new(resp_body); - let wrapper: ResponseWrapper<Site> = serde_json::from_reader(gz).unwrap(); + let wrapper: ResponseWrapper<Site> = serde_json::from_reader(gz).map_err(|e| { + Error::se(format!( + "Error decoding sites from the StackExchange API: {}", + e + )) + })?; self.sites = Some(wrapper.items); - self.store_local_sites(); + self.store_local_sites() } - fn store_local_sites(&self) { - let file = File::create(&self.filename).unwrap(); - serde_json::to_writer(file, &self.sites).unwrap(); + fn store_local_sites(&self) -> Result<()> { + let file = File::create(&self.filename).map_err(|_| Error::create_file(&self.filename))?; + serde_json::to_writer(file, &self.sites).map_err(|_| Error::write_file(&self.filename)) } } /// Creates url from const string; can technically panic -fn stackechange_url(path: &str) -> Url { +fn stackexchange_url(path: &str) -> Url { let mut url = Url::parse(SE_URL).unwrap(); url.set_path(path); url diff --git a/src/term.rs b/src/term.rs new file mode 100644 index 0000000..9356bf6 --- /dev/null +++ b/src/term.rs @@ -0,0 +1,60 @@ +use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; +use crossterm::QueueableCommand; +use std::io::{Stderr, Write}; + +pub trait ColoredOutput { + fn queue_general(&mut self, color: Color, prefix: &str, s: &str) -> &mut Self; + + // TODO is it cool to unwrap flushing some known text? + fn unsafe_flush(&mut self); + + fn queue_error(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Red, "✖ ", s) + } + + fn queue_success(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Green, "✔ ", s) + } + + fn queue_notice(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Yellow, "➜ ", s) + } + + fn queue_notice_inline(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Yellow, "", s) + } + + fn queue_log(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Blue, "• ", s) + } + + fn queue_code(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Cyan, "\t", s) + } + + fn queue_code_inline(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Cyan, "", s) + } + + fn queue_warn(&mut self, s: &str) -> &mut Self { + self.queue_general(Color::Magenta, "⚡", s) + } +} + +impl ColoredOutput for Stderr { + fn queue_general(&mut self, color: Color, prefix: &str, s: &str) -> &mut Self { + (|| -> Result<(), crossterm::ErrorKind> { + self.queue(SetForegroundColor(color))? + .queue(Print(prefix.to_string()))? + .queue(Print(s.to_string()))? + .queue(ResetColor)?; + Ok(()) + })() + .unwrap(); + self + } + + fn unsafe_flush(&mut self) { + self.flush().unwrap(); + } +} |