From df07eec5fae276b70ec3cd3439e6c23d79888c17 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:48:11 -0500 Subject: [PATCH] feat: added reports command and added support for json config --- src/cli.rs | 207 +++++++++++++++++- src/client.rs | 92 +++++++- src/commands/mod.rs | 2 + src/commands/reports.rs | 472 ++++++++++++++++++++++++++++++++++++++++ src/config.rs | 215 ++++++++++++++++++ src/lib.rs | 91 +++++++- src/output.rs | 58 ++++- 7 files changed, 1124 insertions(+), 13 deletions(-) create mode 100644 src/commands/reports.rs create mode 100644 src/config.rs diff --git a/src/cli.rs b/src/cli.rs index b1d2b8f..2fcc60e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,16 +10,19 @@ use uuid::Uuid; pub struct Cli { #[arg(long)] #[arg(short = 'k')] - #[arg(env = "ONSPRING_API_KEY")] #[arg(help = "The API key for the Onspring instance")] pub api_key: Option, #[arg(long)] #[arg(short = 'u')] - #[arg(env = "ONSPRING_BASE_URL")] #[arg(help = "The base URL for the Onspring API")] pub base_url: Option, + #[arg(long)] + #[arg(short = 'c')] + #[arg(help = "Path to a configuration JSON file")] + pub config: Option, + #[arg(long)] #[arg(short = 'p')] #[arg(help = "Pretty-print JSON output")] @@ -64,6 +67,12 @@ pub enum Command { #[command(subcommand)] command: ListsCommand, }, + + #[command(about = "Perform operations against the reports in the instance")] + Reports { + #[command(subcommand)] + command: ReportsCommand, + } } #[derive(Debug, PartialEq, Subcommand)] @@ -319,7 +328,7 @@ pub enum ListsCommand { #[arg(short = 'l')] #[arg(help = "List id for list where the item should be saved")] list_id: i32, - + #[command(flatten)] body: BodySource, }, @@ -338,6 +347,38 @@ pub enum ListsCommand { }, } +#[derive(Debug, PartialEq, Subcommand)] +pub enum ReportsCommand { + #[command(about = "Get the data for a specific report")] + Get { + #[arg(long = "report")] + #[arg(short = 'r')] + #[arg(help = "Report id whose data should be retrieved")] + report_id: i32, + + #[arg(long = "format")] + #[arg(short = 'f')] + #[arg(help = "Format of the report data to be retrieved")] + data_format: Option, + + #[arg(long = "type")] + #[arg(short = 't')] + #[arg(help = "Type of the report data to be retrieved")] + data_type: Option, + }, + + #[command(about = "List the reports available in an app or survey")] + List { + #[arg(long = "app")] + #[arg(short = 'a')] + #[arg(help = "The id of the app whose reports should be listed")] + app_id: i32, + + #[command(flatten)] + paging: PagingArgs, + }, +} + #[derive(Debug, PartialEq, Clone, Args, Default)] pub struct PagingArgs { #[arg(long)] @@ -374,6 +415,12 @@ pub enum DataFormatArg { Formatted, } +#[derive(Debug, PartialEq, Clone, Copy, ValueEnum)] +pub enum ReportDataTypeArg { + ReportData, + ChartData, +} + pub fn parse_cli_from(args: I) -> crate::error::CliResult where I: IntoIterator, @@ -401,6 +448,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Ping, }) @@ -416,6 +464,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Apps { command: AppsCommand::List(PagingArgs::default()), @@ -441,6 +490,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Apps { command: AppsCommand::List(PagingArgs { @@ -461,6 +511,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Apps { command: AppsCommand::Get { app_id: 123 }, @@ -478,6 +529,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Apps { command: AppsCommand::Get { app_id: 123 }, @@ -504,6 +556,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Apps { command: AppsCommand::BatchGet { ids: vec![1, 2, 3] }, @@ -522,6 +575,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Apps { command: AppsCommand::BatchGet { ids: vec![] }, @@ -593,6 +647,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Fields { command: FieldsCommand::List { @@ -623,6 +678,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Fields { command: FieldsCommand::List { @@ -655,6 +711,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Fields { command: FieldsCommand::Get { field_id: 123 }, @@ -672,6 +729,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Fields { command: FieldsCommand::Get { field_id: 123 }, @@ -698,6 +756,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Fields { command: FieldsCommand::BatchGet { ids: vec![1, 2, 3] }, @@ -716,6 +775,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Fields { command: FieldsCommand::BatchGet { ids: vec![] }, @@ -763,6 +823,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::List { @@ -799,6 +860,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::List { @@ -836,6 +898,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::Get { @@ -858,6 +921,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::Save { @@ -881,6 +945,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::Delete { @@ -901,6 +966,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::BatchGet { @@ -924,6 +990,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::Query { @@ -957,6 +1024,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Records { command: RecordsCommand::BatchDelete { @@ -991,6 +1059,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Files { command: FilesCommand::Info { @@ -1014,6 +1083,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Files { command: FilesCommand::Get { @@ -1054,6 +1124,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Files { command: FilesCommand::Upload { @@ -1082,6 +1153,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Files { command: FilesCommand::Delete { @@ -1106,7 +1178,13 @@ mod tests { #[test] fn parse_cli_from_when_called_with_lists_save_item_it_should_return_cli() { let result = parse_cli_from([ - "test", "lists", "save-item", "-l", "1", "-j", r#"{"name":"Item 1"}"#, + "test", + "lists", + "save-item", + "-l", + "1", + "-j", + r#"{"name":"Item 1"}"#, ]); assert_eq!( @@ -1114,6 +1192,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Lists { command: ListsCommand::SaveItem { @@ -1146,6 +1225,7 @@ mod tests { Ok(Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Lists { command: ListsCommand::DeleteItem { @@ -1165,4 +1245,123 @@ mod tests { let err = result.unwrap_err(); assert_eq!(err.code, 2); } + + #[test] + fn parse_cli_from_when_called_with_reports_get_it_should_return_cli() { + let result = parse_cli_from(["test", "reports", "get", "-r", "123"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + config: None, + pretty: false, + command: Command::Reports { + command: ReportsCommand::Get { + report_id: 123, + data_format: None, + data_type: None, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_reports_get_with_options_it_should_return_cli() { + let result = parse_cli_from([ + "test", + "reports", + "get", + "--report", + "456", + "--format", + "formatted", + "--type", + "chart-data", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + config: None, + pretty: false, + command: Command::Reports { + command: ReportsCommand::Get { + report_id: 456, + data_format: Some(DataFormatArg::Formatted), + data_type: Some(ReportDataTypeArg::ChartData), + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_reports_list_it_should_return_cli() { + let result = parse_cli_from(["test", "reports", "list", "-a", "10", "-n", "2", "-s", "25"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + config: None, + pretty: false, + command: Command::Reports { + command: ReportsCommand::List { + app_id: 10, + paging: PagingArgs { + page_number: Some(2), + page_size: Some(25), + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_reports_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test", "reports"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + + #[test] + fn parse_cli_from_when_called_with_config_flag_it_should_return_cli_with_config_path() { + let result = parse_cli_from(["test", "--config", "custom/config.json", "ping"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + config: Some(PathBuf::from("custom/config.json")), + pretty: false, + command: Command::Ping, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_short_config_flag_it_should_return_cli_with_config_path() { + let result = parse_cli_from(["test", "-c", "custom/config.json", "ping"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + config: Some(PathBuf::from("custom/config.json")), + pretty: false, + command: Command::Ping, + }) + ); + } } diff --git a/src/client.rs b/src/client.rs index feca4ac..8312d61 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,8 +1,9 @@ use onspring::{ App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError, - PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveFileRequest, SaveListItemRequest, - SaveListItemResponse, SaveRecordRequest, SaveRecordResponse, + PagedResponse, PagingRequest, QueryRecordsRequest, Record, ReportData, ReportDataType, + ReportInfo, SaveFileRequest, SaveListItemRequest, SaveListItemResponse, SaveRecordRequest, + SaveRecordResponse, }; use uuid::Uuid; @@ -120,6 +121,19 @@ pub trait OnspringRunner { list_id: i32, item_id: Uuid, ) -> impl std::future::Future> + Send; + + fn get_report( + &self, + report_id: i32, + data_format: Option, + data_type: Option, + ) -> impl std::future::Future> + Send; + + fn list_reports( + &self, + app_id: i32, + paging: Option, + ) -> impl std::future::Future, OnspringError>> + Send; } impl OnspringRunner for OnspringClient { @@ -256,6 +270,23 @@ impl OnspringRunner for OnspringClient { async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> { self.delete_list_item(list_id, item_id).await } + + async fn get_report( + &self, + report_id: i32, + data_format: Option, + data_type: Option, + ) -> Result { + self.get_report(report_id, data_format, data_type).await + } + + async fn list_reports( + &self, + app_id: i32, + paging: Option, + ) -> Result, OnspringError> { + self.list_reports(app_id, paging).await + } } #[cfg(test)] @@ -333,6 +364,16 @@ pub mod testing { pub delete_list_item_list_id: Mutex>, pub delete_list_item_item_id: Mutex>, + + pub get_report_result: Result, + pub list_reports_result: Result, OnspringError>, + + pub get_report_report_id: Mutex>, + pub get_report_data_format: Mutex>>, + pub get_report_data_type: Mutex>>, + + pub list_reports_app_id: Mutex>, + pub list_reports_paging: Mutex>>, } impl Default for MockClient { @@ -479,6 +520,25 @@ pub mod testing { delete_list_item_list_id: Mutex::new(None), delete_list_item_item_id: Mutex::new(None), + + get_report_result: Ok(ReportData { + columns: None, + rows: None, + }), + list_reports_result: Ok(PagedResponse { + page_number: None, + page_size: None, + total_pages: None, + total_records: None, + items: None, + }), + + get_report_report_id: Mutex::new(None), + get_report_data_format: Mutex::new(None), + get_report_data_type: Mutex::new(None), + + list_reports_app_id: Mutex::new(None), + list_reports_paging: Mutex::new(None), } } } @@ -731,5 +791,33 @@ pub mod testing { Err(err) => Err(clone_onspring_error(err)), } } + + async fn get_report( + &self, + report_id: i32, + data_format: Option, + data_type: Option, + ) -> Result { + *self.get_report_report_id.lock().unwrap() = Some(report_id); + *self.get_report_data_format.lock().unwrap() = Some(data_format); + *self.get_report_data_type.lock().unwrap() = Some(data_type); + match &self.get_report_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn list_reports( + &self, + app_id: i32, + paging: Option, + ) -> Result, OnspringError> { + *self.list_reports_app_id.lock().unwrap() = Some(app_id); + *self.list_reports_paging.lock().unwrap() = Some(paging); + match &self.list_reports_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dbdcac1..f359fc5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod files; pub mod lists; pub mod ping; pub mod records; +pub mod reports; use std::io::Write; @@ -23,5 +24,6 @@ pub async fn handle( Command::Records { command } => records::handle(command, client, writer, cli.pretty).await, Command::Files { command } => files::handle(command, client, writer, cli.pretty).await, Command::Lists { command } => lists::handle(command, client, writer, cli.pretty).await, + Command::Reports { command } => reports::handle(command, client, writer, cli.pretty).await, } } diff --git a/src/commands/reports.rs b/src/commands/reports.rs new file mode 100644 index 0000000..2efce06 --- /dev/null +++ b/src/commands/reports.rs @@ -0,0 +1,472 @@ +use std::io::Write; + +use onspring::{DataFormat, ReportDataType}; + +use crate::cli::{ReportDataTypeArg, ReportsCommand}; +use crate::client::OnspringRunner; +use crate::error::CliResult; +use crate::output::{PagedOutput, ReportDataOutput, ReportInfoOutput, write_json}; +use crate::validation::{paging_request, validate_positive_i32}; + +impl From for ReportDataType { + fn from(value: ReportDataTypeArg) -> Self { + match value { + ReportDataTypeArg::ReportData => ReportDataType::ReportData, + ReportDataTypeArg::ChartData => ReportDataType::ChartData, + } + } +} + +pub async fn handle( + command: &ReportsCommand, + client: &C, + writer: &mut W, + pretty: bool, +) -> CliResult<()> { + match command { + ReportsCommand::Get { + report_id, + data_format, + data_type, + } => { + validate_positive_i32(*report_id, "report_id")?; + let data_format = data_format.map(DataFormat::from); + let data_type = data_type.map(ReportDataType::from); + let response = client.get_report(*report_id, data_format, data_type).await?; + let output: ReportDataOutput = response.into(); + write_json(writer, &output, pretty)?; + } + ReportsCommand::List { app_id, paging } => { + validate_positive_i32(*app_id, "app_id")?; + let paging = paging_request(paging)?; + let response = client.list_reports(*app_id, paging).await?; + let output: PagedOutput = response.into(); + write_json(writer, &output, pretty)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use onspring::{OnspringError, PagedResponse, ReportData, ReportInfo, ReportRow}; + use serde_json::json; + + use crate::cli::{DataFormatArg, PagingArgs}; + use crate::client::testing::MockClient; + use crate::error::CliError; + + #[tokio::test] + async fn handle_when_reports_get_succeeds_it_should_write_report_data_output() { + let mock_client = MockClient { + get_report_result: Ok(ReportData { + columns: Some(vec!["Col1".to_string(), "Col2".to_string()]), + rows: Some(vec![ReportRow { + record_id: Some(10), + cells: Some(vec![json!("Val1"), json!(42)]), + }]), + }), + ..Default::default() + }; + + let command = ReportsCommand::Get { + report_id: 123, + data_format: None, + data_type: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + assert_eq!( + written.trim(), + r#"{"columns":["Col1","Col2"],"rows":[{"recordId":10,"cells":["Val1",42]}]}"# + ); + assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(123)); + assert_eq!( + *mock_client.get_report_data_format.lock().unwrap(), + Some(None) + ); + assert_eq!(*mock_client.get_report_data_type.lock().unwrap(), Some(None)); + } + + #[tokio::test] + async fn handle_when_reports_get_with_format_and_type_it_should_pass_mapped_arguments() { + let mock_client = MockClient { + get_report_result: Ok(ReportData { + columns: Some(vec!["Header".to_string()]), + rows: Some(vec![]), + }), + ..Default::default() + }; + + let command = ReportsCommand::Get { + report_id: 456, + data_format: Some(DataFormatArg::Formatted), + data_type: Some(ReportDataTypeArg::ChartData), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(456)); + assert_eq!( + *mock_client.get_report_data_format.lock().unwrap(), + Some(Some(DataFormat::Formatted)) + ); + assert_eq!( + *mock_client.get_report_data_type.lock().unwrap(), + Some(Some(ReportDataType::ChartData)) + ); + } + + #[tokio::test] + async fn handle_when_reports_get_with_raw_and_report_data_it_should_pass_mapped_arguments() { + let mock_client = MockClient { + get_report_result: Ok(ReportData { + columns: None, + rows: None, + }), + ..Default::default() + }; + + let command = ReportsCommand::Get { + report_id: 789, + data_format: Some(DataFormatArg::Raw), + data_type: Some(ReportDataTypeArg::ReportData), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(789)); + assert_eq!( + *mock_client.get_report_data_format.lock().unwrap(), + Some(Some(DataFormat::Raw)) + ); + assert_eq!( + *mock_client.get_report_data_type.lock().unwrap(), + Some(Some(ReportDataType::ReportData)) + ); + } + + #[tokio::test] + async fn handle_when_reports_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + get_report_result: Ok(ReportData { + columns: Some(vec!["Col".to_string()]), + rows: Some(vec![ReportRow { + record_id: Some(1), + cells: Some(vec![json!("Val")]), + }]), + }), + ..Default::default() + }; + + let command = ReportsCommand::Get { + report_id: 1, + data_format: None, + data_type: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, true).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + let expected = concat!( + "{\n", + " \"columns\": [\n", + " \"Col\"\n", + " ],\n", + " \"rows\": [\n", + " {\n", + " \"recordId\": 1,\n", + " \"cells\": [\n", + " \"Val\"\n", + " ]\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_reports_get_has_invalid_report_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = ReportsCommand::Get { + report_id: 0, + data_format: None, + data_type: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("report_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert!(mock_client.get_report_report_id.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_reports_get_has_negative_report_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = ReportsCommand::Get { + report_id: -5, + data_format: None, + data_type: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("report_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert!(mock_client.get_report_report_id.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_reports_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + get_report_result: Err(OnspringError::Api { + status_code: 404, + message: "Report not found".to_string(), + }), + ..Default::default() + }; + + let command = ReportsCommand::Get { + report_id: 999, + data_format: None, + data_type: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime("API request failed (404): Report not found")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_reports_list_succeeds_it_should_write_paged_output() { + let mock_client = MockClient { + list_reports_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![ReportInfo { + app_id: 10, + id: 100, + name: Some("Open Tasks".to_string()), + description: Some("Report of open tasks".to_string()), + }]), + }), + ..Default::default() + }; + + let command = ReportsCommand::List { + app_id: 10, + paging: PagingArgs::default(), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + assert_eq!( + written.trim(), + r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"appId":10,"id":100,"name":"Open Tasks","description":"Report of open tasks"}]}"# + ); + assert_eq!(*mock_client.list_reports_app_id.lock().unwrap(), Some(10)); + assert!( + mock_client + .list_reports_paging + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn handle_when_reports_list_with_paging_args_it_should_pass_paging_request() { + let mock_client = MockClient { + list_reports_result: Ok(PagedResponse { + page_number: Some(2), + page_size: Some(15), + total_pages: Some(3), + total_records: Some(40), + items: Some(vec![]), + }), + ..Default::default() + }; + + let command = ReportsCommand::List { + app_id: 10, + paging: PagingArgs { + page_number: Some(2), + page_size: Some(15), + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let paging = mock_client + .list_reports_paging + .lock() + .unwrap() + .take() + .unwrap() + .unwrap(); + assert_eq!(paging.page_number, 2); + assert_eq!(paging.page_size, 15); + } + + #[tokio::test] + async fn handle_when_reports_list_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + list_reports_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![ReportInfo { + app_id: 10, + id: 100, + name: Some("Open Tasks".to_string()), + description: None, + }]), + }), + ..Default::default() + }; + + let command = ReportsCommand::List { + app_id: 10, + paging: PagingArgs::default(), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, true).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + let expected = concat!( + "{\n", + " \"pageNumber\": 1,\n", + " \"pageSize\": 50,\n", + " \"totalPages\": 1,\n", + " \"totalRecords\": 1,\n", + " \"items\": [\n", + " {\n", + " \"appId\": 10,\n", + " \"id\": 100,\n", + " \"name\": \"Open Tasks\",\n", + " \"description\": null\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_reports_list_has_invalid_app_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = ReportsCommand::List { + app_id: 0, + paging: PagingArgs::default(), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("app_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert!(mock_client.list_reports_app_id.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_reports_list_has_invalid_paging_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = ReportsCommand::List { + app_id: 10, + paging: PagingArgs { + page_number: Some(-1), + page_size: None, + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("page_number must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert!(mock_client.list_reports_paging.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_reports_list_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + list_reports_result: Err(OnspringError::Api { + status_code: 403, + message: "Forbidden".to_string(), + }), + ..Default::default() + }; + + let command = ReportsCommand::List { + app_id: 10, + paging: PagingArgs::default(), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime("API request failed (403): Forbidden")) + ); + assert!(buffer.is_empty()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..aa41891 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,215 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::cli::Cli; +use crate::error::{CliError, CliResult}; + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigFile { + pub api_key: Option, + pub base_url: Option, + pub pretty: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedConfig { + pub api_key: Option, + pub base_url: Option, + pub pretty: bool, +} + +pub fn default_config_path() -> Option { + let home = std::env::var("HOME") + .ok() + .or_else(|| std::env::var("USERPROFILE").ok())?; + + Some(PathBuf::from(home).join(".config").join("onx").join("config.json")) +} + +pub fn load_config_file(path: &Path, is_explicit: bool) -> CliResult> { + if !path.exists() { + if is_explicit { + return Err(CliError::usage(format!( + "Config file not found: {}", + path.display() + ))); + } + return Ok(None); + } + + let contents = fs::read_to_string(path).map_err(|err| { + CliError::runtime(format!( + "Failed to read config file {}: {err}", + path.display() + )) + })?; + + let config: ConfigFile = serde_json::from_str(&contents).map_err(|err| { + CliError::runtime(format!( + "Failed to parse config file {}: {err}", + path.display() + )) + })?; + + Ok(Some(config)) +} + +pub fn resolve_config(cli: &Cli, config_file: Option<&ConfigFile>) -> ResolvedConfig { + let config_file_default = ConfigFile::default(); + let file = config_file.unwrap_or(&config_file_default); + + // Precedence: CLI Flag > Config File > Environment Variable + let api_key = cli + .api_key + .clone() + .or_else(|| file.api_key.clone()) + .or_else(|| std::env::var("ONSPRING_API_KEY").ok()); + + let base_url = cli + .base_url + .clone() + .or_else(|| file.base_url.clone()) + .or_else(|| std::env::var("ONSPRING_BASE_URL").ok()); + + let pretty = if cli.pretty { + true + } else { + file.pretty.unwrap_or(false) + }; + + ResolvedConfig { + api_key, + base_url, + pretty, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Command; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn load_config_file_when_file_exists_it_should_parse_camel_case_json() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{"apiKey":"file-key","baseUrl":"https://config.onspring.com","pretty":true}}"# + ) + .unwrap(); + + let loaded = load_config_file(file.path(), true).unwrap(); + assert_eq!( + loaded, + Some(ConfigFile { + api_key: Some("file-key".to_string()), + base_url: Some("https://config.onspring.com".to_string()), + pretty: Some(true), + }) + ); + } + + #[test] + fn load_config_file_when_not_explicit_and_file_missing_it_should_return_none() { + let missing_path = PathBuf::from("non_existent_config_file_path_12345.json"); + let loaded = load_config_file(&missing_path, false).unwrap(); + assert_eq!(loaded, None); + } + + #[test] + fn load_config_file_when_explicit_and_file_missing_it_should_return_usage_error() { + let missing_path = PathBuf::from("non_existent_config_file_path_12345.json"); + let err = load_config_file(&missing_path, true).unwrap_err(); + assert_eq!(err.code, 2); + assert!(err.message.contains("Config file not found")); + } + + #[test] + fn load_config_file_when_invalid_json_it_should_return_runtime_error() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "not json").unwrap(); + + let err = load_config_file(file.path(), true).unwrap_err(); + assert_eq!(err.code, 1); + assert!(err.message.contains("Failed to parse config file")); + } + + #[test] + fn default_config_path_when_home_or_userprofile_set_it_should_resolve_path() { + let path = default_config_path(); + assert!(path.is_some()); + let p = path.unwrap(); + assert!(p.ends_with(".config/onx/config.json") || p.ends_with(r#".config\onx\config.json"#)); + } + + #[test] + fn resolve_config_when_cli_flag_provided_it_should_take_precedence() { + let cli = Cli { + api_key: Some("cli-key".to_string()), + base_url: Some("https://cli.onspring.com".to_string()), + config: None, + pretty: true, + command: Command::Ping, + }; + let file = ConfigFile { + api_key: Some("file-key".to_string()), + base_url: Some("https://file.onspring.com".to_string()), + pretty: Some(false), + }; + + let resolved = resolve_config(&cli, Some(&file)); + assert_eq!( + resolved, + ResolvedConfig { + api_key: Some("cli-key".to_string()), + base_url: Some("https://cli.onspring.com".to_string()), + pretty: true, + } + ); + } + + #[test] + fn resolve_config_when_cli_flag_absent_it_should_use_config_file() { + let cli = Cli { + api_key: None, + base_url: None, + config: None, + pretty: false, + command: Command::Ping, + }; + let file = ConfigFile { + api_key: Some("file-key".to_string()), + base_url: Some("https://file.onspring.com".to_string()), + pretty: Some(true), + }; + + let resolved = resolve_config(&cli, Some(&file)); + assert_eq!( + resolved, + ResolvedConfig { + api_key: Some("file-key".to_string()), + base_url: Some("https://file.onspring.com".to_string()), + pretty: true, + } + ); + } + + #[test] + fn resolve_config_when_no_file_it_should_default_pretty_to_false() { + let cli = Cli { + api_key: None, + base_url: None, + config: None, + pretty: false, + command: Command::Ping, + }; + + let resolved = resolve_config(&cli, None); + assert!(!resolved.pretty); + } +} diff --git a/src/lib.rs b/src/lib.rs index fa54b83..0a3d87d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod cli; mod client; mod commands; +mod config; mod error; mod output; mod validation; @@ -9,17 +10,28 @@ use onspring::OnspringClient; pub use cli::{Cli, Command, parse_cli_from}; pub use client::OnspringRunner; +pub use config::{ConfigFile, ResolvedConfig, default_config_path, load_config_file, resolve_config}; pub use error::{CliError, CliResult, render_cli_error}; -pub async fn run(cli: Cli) -> CliResult<()> { - let api_key = cli +pub async fn run(mut cli: Cli) -> CliResult<()> { + let config_file = if let Some(config_path) = &cli.config { + load_config_file(config_path, true)? + } else if let Some(default_path) = default_config_path() { + load_config_file(&default_path, false)? + } else { + None + }; + + let resolved = resolve_config(&cli, config_file.as_ref()); + + let api_key = resolved .api_key .as_deref() - .ok_or_else(|| CliError::usage("Missing API Key. Set --api-key or ONSPRING_API_KEY."))?; + .ok_or_else(|| CliError::usage("Missing API Key. Set --api-key, config file, or ONSPRING_API_KEY."))?; let mut builder = OnspringClient::builder(api_key.to_string()); - if let Some(base_url) = &cli.base_url { + if let Some(base_url) = &resolved.base_url { if base_url.trim().is_empty() { return Err(CliError::usage("Base URL cannot be empty.")); } @@ -27,6 +39,8 @@ pub async fn run(cli: Cli) -> CliResult<()> { builder = builder.base_url(base_url); } + cli.pretty = resolved.pretty; + let client = builder.build(); let mut stdout = std::io::stdout(); run_with_client(&cli, &client, &mut stdout).await @@ -43,12 +57,15 @@ pub async fn run_with_client( #[cfg(test)] mod tests { use super::*; + use std::io::Write; + use tempfile::NamedTempFile; #[tokio::test] async fn run_when_missing_api_key_it_should_return_usage_error() { let cli = Cli { api_key: None, base_url: None, + config: None, pretty: false, command: Command::Ping, }; @@ -58,16 +75,59 @@ mod tests { assert_eq!( result, Err(CliError::usage( - "Missing API Key. Set --api-key or ONSPRING_API_KEY." + "Missing API Key. Set --api-key, config file, or ONSPRING_API_KEY." )) ); } + #[tokio::test] + async fn run_when_api_key_in_config_file_it_should_succeed() { + let mut config_file = NamedTempFile::new().unwrap(); + writeln!(config_file, r#"{{"apiKey":"config-key"}}"#).unwrap(); + + let cli = Cli { + api_key: None, + base_url: None, + config: Some(config_file.path().to_path_buf()), + pretty: false, + command: Command::Ping, + }; + + // run builds the client with config-key and calls ping + // Since OnspringClient builder does not hit network until execute, + // calling run will fail at runtime network request or succeed if network is mocked, + // but here run creates an OnspringClient and executes ping against real endpoint (network error). + let result = run(cli).await; + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn run_when_empty_base_url_in_config_file_it_should_return_usage_error() { + let mut config_file = NamedTempFile::new().unwrap(); + writeln!( + config_file, + r#"{{"apiKey":"config-key","baseUrl":" "}}"# + ) + .unwrap(); + + let cli = Cli { + api_key: None, + base_url: None, + config: Some(config_file.path().to_path_buf()), + 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_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()), + config: None, pretty: false, command: Command::Ping, }; @@ -77,12 +137,33 @@ mod tests { assert_eq!(result, Err(CliError::usage("Base URL cannot be empty."))); } + #[tokio::test] + async fn run_when_config_file_has_invalid_json_it_should_return_runtime_error() { + let mut config_file = NamedTempFile::new().unwrap(); + writeln!(config_file, "invalid-json").unwrap(); + + let cli = Cli { + api_key: None, + base_url: None, + config: Some(config_file.path().to_path_buf()), + pretty: false, + command: Command::Ping, + }; + + let result = run(cli).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 1); + assert!(err.message.contains("Failed to parse config file")); + } + #[tokio::test] async fn run_with_client_it_should_delegate_to_commands() { let mock_client = client::testing::MockClient::default(); let cli = Cli { api_key: Some("key".to_string()), base_url: None, + config: None, pretty: false, command: Command::Ping, }; diff --git a/src/output.rs b/src/output.rs index 46c0b65..a2116d2 100644 --- a/src/output.rs +++ b/src/output.rs @@ -3,8 +3,8 @@ use std::io::Write; use chrono::{DateTime, Utc}; use onspring::{ App, CollectionResponse, CreatedWithIdResponse, Field, FileInfo, FormulaOutputType, - ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, SaveListItemResponse, - SaveRecordResponse, ValueType, + ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, ReportData, ReportInfo, + ReportRow, SaveListItemResponse, SaveRecordResponse, ValueType, }; use serde::Serialize; use uuid::Uuid; @@ -263,6 +263,60 @@ impl From for SaveListItemResponseOutput { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportInfoOutput { + pub app_id: i32, + pub id: i32, + pub name: Option, + pub description: Option, +} + +impl From for ReportInfoOutput { + fn from(value: ReportInfo) -> Self { + Self { + app_id: value.app_id, + id: value.id, + name: value.name, + description: value.description, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportRowOutput { + pub record_id: Option, + pub cells: Option>, +} + +impl From for ReportRowOutput { + fn from(value: ReportRow) -> Self { + Self { + record_id: value.record_id, + cells: value.cells, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportDataOutput { + pub columns: Option>, + pub rows: Option>, +} + +impl From for ReportDataOutput { + fn from(value: ReportData) -> Self { + Self { + columns: value.columns, + rows: value + .rows + .map(|rows| rows.into_iter().map(ReportRowOutput::from).collect()), + } + } +} + 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"))