From 8045814b3b30bfe7ab10c7de0c61926b01dc196a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:09:25 -0500 Subject: [PATCH] feat: implement ping command" --- src/cli.rs | 11 +- src/lib.rs | 281 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/output.rs | 31 +++++- 3 files changed, 313 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index efc0d13..903ef1e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::Parser; +use clap::{Parser, Subcommand}; #[derive(Debug, PartialEq, Parser)] #[command(name = "onx")] @@ -22,4 +22,13 @@ pub struct Cli { #[arg(help = "Pretty-print JSON output")] #[arg(default_value_t = false)] pub pretty: bool, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, PartialEq, Subcommand)] +pub enum Command { + #[command(about = "Check connectivity to the Onspring API")] + Ping, } diff --git a/src/lib.rs b/src/lib.rs index fe87acc..8e810a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,9 +2,22 @@ mod cli; mod output; use clap::{Parser, error::ErrorKind}; -use cli::Cli; +use cli::{Cli, Command}; -use output::render_error; +use onspring::{OnspringClient, OnspringError}; +use output::{SuccessResponse, render_error, write_json}; + +pub type CliResult = Result; + +pub trait OnspringRunner { + fn ping(&self) -> impl std::future::Future> + Send; +} + +impl OnspringRunner for OnspringClient { + async fn ping(&self) -> Result<(), OnspringError> { + self.ping().await + } +} #[derive(Debug, PartialEq)] pub struct CliError { @@ -35,9 +48,59 @@ impl CliError { } } -pub type CliResult = Result; +impl From for CliError { + fn from(value: OnspringError) -> Self { + match value { + OnspringError::InvalidArgument(message) => Self::usage(format!("Invalid request: {message}")), + OnspringError::Api { + status_code, + message, + } => { + if message.is_empty() { + Self::runtime(format!("API request failed with status {status_code}.")) + } else { + Self::runtime(format!("API request failed ({status_code}): {message}")) + } + } + OnspringError::Http(_) => Self::runtime("Network request failed."), + OnspringError::Serialization(_) => Self::runtime("Failed to parse API response."), + } + } +} + +pub async fn run(cli: Cli) -> CliResult<()> { + let api_key = cli + .api_key + .as_deref() + .ok_or_else(|| CliError::usage("Missing API Key. Set --api-key or ONSPRING_API_KEY."))?; + + let mut builder = OnspringClient::builder(api_key.to_string()); + + if let Some(base_url) = &cli.base_url { + if base_url.trim().is_empty() { + return Err(CliError::usage("Base URL cannot be empty.")); + } + + builder = builder.base_url(base_url); + } + + let client = builder.build(); + let mut stdout = std::io::stdout(); + run_with_client(&cli, &client, &mut stdout).await +} + +pub async fn run_with_client( + cli: &Cli, + client: &C, + writer: &mut W, +) -> CliResult<()> { + match cli.command { + Command::Ping => { + client.ping().await?; + write_json(writer, &SuccessResponse { ok: true }, cli.pretty)?; + } + } -pub async fn run(_cli: Cli) -> CliResult<()> { Ok(()) } @@ -61,20 +124,60 @@ pub fn render_cli_error(err: &CliError, pretty: bool) -> String { mod tests { use super::*; + struct MockClient { + ping_result: Result<(), OnspringError>, + } + + impl OnspringRunner for MockClient { + async fn ping(&self) -> Result<(), OnspringError> { + match &self.ping_result { + Ok(()) => Ok(()), + Err(OnspringError::InvalidArgument(msg)) => { + Err(OnspringError::InvalidArgument(msg.clone())) + } + Err(OnspringError::Api { + status_code, + message, + }) => Err(OnspringError::Api { + status_code: *status_code, + message: message.clone(), + }), + Err(OnspringError::Serialization(_)) => { + let serde_err = serde_json::from_str::("invalid").unwrap_err(); + Err(OnspringError::Serialization(serde_err)) + } + Err(OnspringError::Http(_)) => { + Err(OnspringError::InvalidArgument("http error".to_string())) + } + } + } + } + #[test] - fn parse_cli_from_when_called_with_valid_program_name_it_should_return_cli() { - let result = parse_cli_from(["test"]); + fn parse_cli_from_when_called_with_valid_subcommand_it_should_return_cli() { + let result = parse_cli_from(["test", "ping"]); assert_eq!( result, Ok(Cli { api_key: None, base_url: None, - pretty: false + pretty: false, + command: Command::Ping, }) ); } + #[test] + fn parse_cli_from_when_called_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + assert!(err.message.contains("Usage: test [OPTIONS] ")); + } + #[test] fn parse_cli_from_when_called_with_unknown_flag_it_should_return_usage_error() { let result = parse_cli_from(["test", "--bogus"]); @@ -125,5 +228,167 @@ mod tests { r#"{"error":{"code":1,"message":"Something went wrong"}}"# ); } -} + #[test] + fn cli_error_constructors_should_set_expected_codes_and_messages() { + let info = CliError::info("info message"); + assert_eq!(info.code, 0); + assert_eq!(info.message, "info message"); + + let runtime = CliError::runtime("runtime message"); + assert_eq!(runtime.code, 1); + assert_eq!(runtime.message, "runtime message"); + + let usage = CliError::usage("usage message"); + assert_eq!(usage.code, 2); + assert_eq!(usage.message, "usage message"); + } + + #[test] + fn cli_error_from_onspring_error_invalid_argument() { + let err = OnspringError::InvalidArgument("bad param".to_string()); + let cli_err = CliError::from(err); + + assert_eq!(cli_err.code, 2); + assert_eq!(cli_err.message, "Invalid request: bad param"); + } + + #[test] + fn cli_error_from_onspring_error_api_with_message() { + let err = OnspringError::Api { + status_code: 404, + message: "Resource not found".to_string(), + }; + let cli_err = CliError::from(err); + + assert_eq!(cli_err.code, 1); + assert_eq!( + cli_err.message, + "API request failed (404): Resource not found" + ); + } + + #[test] + fn cli_error_from_onspring_error_api_with_empty_message() { + let err = OnspringError::Api { + status_code: 500, + message: String::new(), + }; + let cli_err = CliError::from(err); + + assert_eq!(cli_err.code, 1); + assert_eq!(cli_err.message, "API request failed with status 500."); + } + + #[test] + fn cli_error_from_onspring_error_serialization() { + let serde_err = serde_json::from_str::("invalid json").unwrap_err(); + let err = OnspringError::Serialization(serde_err); + let cli_err = CliError::from(err); + + assert_eq!(cli_err.code, 1); + assert_eq!(cli_err.message, "Failed to parse API response."); + } + + #[tokio::test] + async fn run_when_missing_api_key_it_should_return_usage_error() { + let cli = Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Ping, + }; + + let result = run(cli).await; + + assert_eq!( + result, + Err(CliError::usage( + "Missing API Key. Set --api-key or ONSPRING_API_KEY." + )) + ); + } + + #[tokio::test] + async fn run_when_empty_base_url_it_should_return_usage_error() { + let cli = Cli { + api_key: Some("test-api-key".to_string()), + base_url: Some(" ".to_string()), + pretty: false, + command: Command::Ping, + }; + + let result = run(cli).await; + + assert_eq!(result, Err(CliError::usage("Base URL cannot be empty."))); + } + + #[tokio::test] + async fn run_with_client_when_ping_succeeds_it_should_write_success_response() { + let mock_client = MockClient { + ping_result: Ok(()), + }; + + let cli = Cli { + api_key: Some("key".to_string()), + base_url: None, + pretty: false, + command: Command::Ping, + }; + + let mut buffer = Vec::new(); + + let result = run_with_client(&cli, &mock_client, &mut buffer).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!(written.trim(), r#"{"ok":true}"#); + } + + #[tokio::test] + async fn run_with_client_when_ping_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + ping_result: Ok(()), + }; + + let cli = Cli { + api_key: Some("key".to_string()), + base_url: None, + pretty: true, + command: Command::Ping, + }; + + let mut buffer = Vec::new(); + + let result = run_with_client(&cli, &mock_client, &mut buffer).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!(written.trim(), "{\n \"ok\": true\n}"); + } + + #[tokio::test] + async fn run_with_client_when_ping_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + ping_result: Err(OnspringError::InvalidArgument("Invalid Key".to_string())), + }; + + let cli = Cli { + api_key: Some("key".to_string()), + base_url: None, + pretty: false, + command: Command::Ping, + }; + + let mut buffer = Vec::new(); + + let result = run_with_client(&cli, &mock_client, &mut buffer).await; + + assert_eq!(result, Err(CliError::usage("Invalid request: Invalid Key"))); + assert!(buffer.is_empty()); + } +} diff --git a/src/output.rs b/src/output.rs index 20fa183..6751114 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,3 +1,5 @@ +use std::io::Write; + use serde::Serialize; use crate::{CliError, CliResult}; @@ -13,6 +15,12 @@ struct ErrorEnvelope<'a> { pub error: ErrorBody<'a>, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SuccessResponse { + pub ok: bool, +} + pub fn render_json(value: &T, pretty: bool) -> CliResult { if pretty { serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output")) @@ -32,6 +40,16 @@ pub fn render_error(err: &CliError, pretty: bool) -> CliResult { render_json(&envelope, pretty) } +pub fn write_json( + writer: &mut W, + value: &T, + pretty: bool, +) -> CliResult<()> { + let rendered = render_json(value, pretty)?; + writeln!(writer, "{rendered}").map_err(|_| CliError::runtime("Failed to write output"))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -75,5 +93,16 @@ mod tests { )); assert_eq!(result, expected); } -} + #[test] + fn write_json_when_called_with_writer_it_should_write_formatted_string() { + let data = json!({"ok": true}); + let mut buffer = Vec::new(); + + let result = write_json(&mut buffer, &data, false); + assert_eq!(result, Ok(())); + + let output_str = String::from_utf8(buffer).unwrap(); + assert_eq!(output_str, "{\"ok\":true}\n"); + } +}