#![deny(warnings)] #![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] #![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] use std::{env, process::ExitCode, time::Duration}; const DEFAULT_BASE_URL: &str = "http://localhost:5000"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::main] async fn main() -> ExitCode { match run(env::args().skip(1)).await { Ok(output) => { println!("{output}"); ExitCode::SUCCESS } Err(CliError::Help) => { eprintln!("{}", usage()); ExitCode::SUCCESS } Err(CliError::Usage(message)) => { eprintln!("{message}\n\n{}", usage()); ExitCode::from(2) } Err(error) => { eprintln!("error: {error}"); ExitCode::FAILURE } } } async fn run(args: I) -> Result where I: IntoIterator, S: Into, { let args = parse_args(args)?; call_api(&args).await } async fn call_api(args: &CliArgs) -> Result { let client = reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) .build()?; let response = client.get(args.url()).send().await?; let status = response.status(); let body = response.text().await?; if status.is_success() { pretty_body(&body) } else { Err(CliError::Api { status, body }) } } fn pretty_body(body: &str) -> Result { serde_json::from_str::(body).map_or_else( |_| Ok(body.to_owned()), |value| serde_json::to_string_pretty(&value).map_err(CliError::from), ) } #[derive(Debug, Eq, PartialEq)] struct CliArgs { base_url: String, command: Command, } impl CliArgs { fn url(&self) -> String { format!("{}{}", self.base_url, self.command.path()) } } #[derive(Debug, Eq, PartialEq)] enum Command { Health, Metrics, Pokemon { name: String }, TranslatedPokemon { name: String }, } impl Command { fn path(&self) -> String { match self { Self::Health => "/health".to_owned(), Self::Metrics => "/metrics".to_owned(), Self::Pokemon { name } => format!("/pokemon/{name}"), Self::TranslatedPokemon { name } => format!("/pokemon/translated/{name}"), } } } fn parse_args(args: I) -> Result where I: IntoIterator, S: Into, { let mut base_url = DEFAULT_BASE_URL.to_owned(); let mut positional = Vec::new(); let mut iter = args.into_iter().map(Into::into); while let Some(token) = iter.next() { match token.as_str() { "--help" | "-h" => return Err(CliError::Help), "--base-url" | "-u" => { base_url = iter .next() .ok_or_else(|| CliError::Usage("missing value for --base-url".to_owned()))?; } _ if token.starts_with('-') => { return Err(CliError::Usage(format!("unknown option: {token}"))); } _ => { positional.push(token); positional.extend(iter); break; } } } Ok(CliArgs { base_url: trim_base_url(&base_url), command: parse_command(&positional)?, }) } fn parse_command(positional: &[String]) -> Result { match positional { [command] if command == "health" => Ok(Command::Health), [command] if command == "metrics" => Ok(Command::Metrics), [command, name] if command == "pokemon" => { parse_pokemon_name(name).map(|name| Command::Pokemon { name: name.to_owned(), }) } [command, name] if command == "translated" => { parse_pokemon_name(name).map(|name| Command::TranslatedPokemon { name: name.to_owned(), }) } [] => Err(CliError::Usage("missing command".to_owned())), _ => Err(CliError::Usage("invalid command or arguments".to_owned())), } } fn parse_pokemon_name(name: &str) -> Result<&str, CliError> { if is_valid_pokemon_name(name) { Ok(name) } else { Err(CliError::Usage( "pokemon name must contain only ASCII letters, digits, or hyphens".to_owned(), )) } } fn is_valid_pokemon_name(name: &str) -> bool { !name.is_empty() && name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') } fn trim_base_url(base_url: &str) -> String { base_url.trim_end_matches('/').to_owned() } const fn usage() -> &'static str { "Usage:\n pokedex-cli [--base-url URL] health\n pokedex-cli [--base-url URL] metrics\n pokedex-cli [--base-url URL] pokemon \n pokedex-cli [--base-url URL] translated \n\nExamples:\n pokedex-cli pokemon mewtwo\n pokedex-cli translated mewtwo\n pokedex-cli --base-url http://localhost:5000 pokemon pikachu" } #[derive(Debug, thiserror::Error)] enum CliError { #[error("API returned {status}: {body}")] Api { status: reqwest::StatusCode, body: String, }, #[error(transparent)] Http(#[from] reqwest::Error), #[error(transparent)] Json(#[from] serde_json::Error), #[error("{0}")] Usage(String), #[error("help requested")] Help, } #[cfg(test)] mod tests { use super::{CliArgs, CliError, Command, DEFAULT_BASE_URL, parse_args, run}; use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; #[test] fn parses_default_base_url_and_pokemon_command() { let args = parse_args(["pokemon", "mewtwo"]).expect("args should parse"); assert_eq!( args, CliArgs { base_url: DEFAULT_BASE_URL.to_owned(), command: Command::Pokemon { name: "mewtwo".to_owned() } } ); assert_eq!(args.url(), "http://localhost:5000/pokemon/mewtwo"); } #[test] fn parses_custom_base_url_and_translated_command() { let args = parse_args([ "--base-url", "http://localhost:5000/", "translated", "mr-mime", ]) .expect("args should parse"); assert_eq!(args.base_url, "http://localhost:5000"); assert_eq!( args.url(), "http://localhost:5000/pokemon/translated/mr-mime" ); } #[test] fn rejects_names_that_do_not_match_the_api_contract() { let error = parse_args(["pokemon", "foo?bar=baz"]).expect_err("args should fail"); assert!(matches!(error, CliError::Usage(_))); } #[tokio::test] async fn calls_api_and_pretty_prints_json() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon/mewtwo")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": "mewtwo", "isLegendary": true }))) .expect(1) .mount(&server) .await; let base_url = server.uri(); let output = run(["--base-url", &base_url, "pokemon", "mewtwo"]) .await .expect("request should succeed"); assert!(output.contains("\"name\": \"mewtwo\"")); assert!(output.contains("\"isLegendary\": true")); } #[tokio::test] async fn returns_api_errors_with_response_body() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon/nope")) .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ "error": "pokemon not found" }))) .mount(&server) .await; let base_url = server.uri(); let error = run(["--base-url", &base_url, "pokemon", "nope"]) .await .expect_err("request should fail"); assert!(error.to_string().contains("404")); assert!(error.to_string().contains("pokemon not found")); } }