diff --git a/src/cli.rs b/src/cli.rs index bc3f15f..b1d2b8f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum}; +use uuid::Uuid; #[derive(Debug, PartialEq, Parser)] #[command(name = "onx")] @@ -57,6 +58,12 @@ pub enum Command { #[command(subcommand)] command: FilesCommand, }, + + #[command(about = "Perform operations against the lists in the instance")] + Lists { + #[command(subcommand)] + command: ListsCommand, + }, } #[derive(Debug, PartialEq, Subcommand)] @@ -244,6 +251,7 @@ pub enum FilesCommand { output: Option, }, + #[command(about = "Upload a file to a field on a record")] Upload { #[arg(long = "record")] #[arg(short = 'r')] @@ -284,6 +292,7 @@ pub enum FilesCommand { modified_date: Option, }, + #[command(about = "Delete a file from a field on a record")] Delete { #[arg(long = "record")] #[arg(short = 'r')] @@ -302,6 +311,33 @@ pub enum FilesCommand { }, } +#[derive(Debug, PartialEq, Subcommand)] +pub enum ListsCommand { + #[command(about = "Add or update an item for a specific list")] + SaveItem { + #[arg(long = "list")] + #[arg(short = 'l')] + #[arg(help = "List id for list where the item should be saved")] + list_id: i32, + + #[command(flatten)] + body: BodySource, + }, + + #[command(about = "Delete an item from a specific list")] + DeleteItem { + #[arg(long = "list")] + #[arg(short = 'l')] + #[arg(help = "List id for list where the item should be saved")] + list_id: i32, + + #[arg(long = "item")] + #[arg(short = 'i')] + #[arg(help = "Id of the item that should be deleted")] + item_id: Uuid, + }, +} + #[derive(Debug, PartialEq, Clone, Args, Default)] pub struct PagingArgs { #[arg(long)] @@ -994,8 +1030,22 @@ mod tests { #[test] fn parse_cli_from_when_called_with_files_upload_it_should_return_cli() { let result = parse_cli_from([ - "test", "files", "upload", "-r", "1", "-f", "2", "-i", "doc.pdf", "-n", "renamed.pdf", - "-t", "application/pdf", "--notes", "test notes", "--modified-date", + "test", + "files", + "upload", + "-r", + "1", + "-f", + "2", + "-i", + "doc.pdf", + "-n", + "renamed.pdf", + "-t", + "application/pdf", + "--notes", + "test notes", + "--modified-date", "2026-01-01T00:00:00Z", ]); @@ -1052,4 +1102,67 @@ mod tests { let err = result.unwrap_err(); assert_eq!(err.code, 2); } + + #[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"}"#, + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Lists { + command: ListsCommand::SaveItem { + list_id: 1, + body: BodySource { + json: Some(r#"{"name":"Item 1"}"#.to_string()), + file: None, + stdin: false, + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_lists_delete_item_it_should_return_cli() { + let result = parse_cli_from([ + "test", + "lists", + "delete-item", + "-l", + "10", + "-i", + "d3b07384-d113-40e1-a20c-55c3c0a4e70e", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Lists { + command: ListsCommand::DeleteItem { + list_id: 10, + item_id: Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(), + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_lists_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test", "lists"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } } diff --git a/src/client.rs b/src/client.rs index 02bda5a..feca4ac 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,9 +1,10 @@ use onspring::{ App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError, - PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveFileRequest, SaveRecordRequest, - SaveRecordResponse, + PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveFileRequest, SaveListItemRequest, + SaveListItemResponse, SaveRecordRequest, SaveRecordResponse, }; +use uuid::Uuid; pub trait OnspringRunner { fn ping(&self) -> impl std::future::Future> + Send; @@ -107,6 +108,18 @@ pub trait OnspringRunner { field_id: i32, file_id: i32, ) -> impl std::future::Future> + Send; + + fn save_list_item( + &self, + list_id: i32, + request: SaveListItemRequest, + ) -> impl std::future::Future> + Send; + + fn delete_list_item( + &self, + list_id: i32, + item_id: Uuid, + ) -> impl std::future::Future> + Send; } impl OnspringRunner for OnspringClient { @@ -231,6 +244,18 @@ impl OnspringRunner for OnspringClient { ) -> Result<(), OnspringError> { self.delete_file(record_id, field_id, file_id).await } + + async fn save_list_item( + &self, + list_id: i32, + request: SaveListItemRequest, + ) -> Result { + self.save_list_item(list_id, request).await + } + + async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> { + self.delete_list_item(list_id, item_id).await + } } #[cfg(test)] @@ -299,6 +324,15 @@ pub mod testing { pub delete_file_record_id: Mutex>, pub delete_file_field_id: Mutex>, pub delete_file_file_id: Mutex>, + + pub save_list_item_result: Result, + pub delete_list_item_result: Result<(), OnspringError>, + + pub save_list_item_list_id: Mutex>, + pub save_list_item_request: Mutex>, + + pub delete_list_item_list_id: Mutex>, + pub delete_list_item_item_id: Mutex>, } impl Default for MockClient { @@ -434,6 +468,17 @@ pub mod testing { delete_file_record_id: Mutex::new(None), delete_file_field_id: Mutex::new(None), delete_file_file_id: Mutex::new(None), + + save_list_item_result: Ok(SaveListItemResponse { + id: Uuid::nil(), + }), + delete_list_item_result: Ok(()), + + save_list_item_list_id: Mutex::new(None), + save_list_item_request: Mutex::new(None), + + delete_list_item_list_id: Mutex::new(None), + delete_list_item_item_id: Mutex::new(None), } } } @@ -664,5 +709,27 @@ pub mod testing { Err(err) => Err(clone_onspring_error(err)), } } + + async fn save_list_item( + &self, + list_id: i32, + request: SaveListItemRequest, + ) -> Result { + *self.save_list_item_list_id.lock().unwrap() = Some(list_id); + *self.save_list_item_request.lock().unwrap() = Some(request); + match &self.save_list_item_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> { + *self.delete_list_item_list_id.lock().unwrap() = Some(list_id); + *self.delete_list_item_item_id.lock().unwrap() = Some(item_id); + match &self.delete_list_item_result { + Ok(()) => Ok(()), + Err(err) => Err(clone_onspring_error(err)), + } + } } } diff --git a/src/commands/lists.rs b/src/commands/lists.rs new file mode 100644 index 0000000..c4e0d69 --- /dev/null +++ b/src/commands/lists.rs @@ -0,0 +1,372 @@ +use std::io::{Read, Write}; + +use onspring::SaveListItemRequest; +use serde::Deserialize; +use uuid::Uuid; + +use crate::cli::ListsCommand; +use crate::client::OnspringRunner; +use crate::error::CliResult; +use crate::output::{SaveListItemResponseOutput, SuccessResponse, write_json}; +use crate::validation::{parse_body, validate_positive_i32}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveListItemInput { + pub id: Option, + pub name: String, + pub numeric_value: Option, + pub color: Option, +} + +impl From for SaveListItemRequest { + fn from(value: SaveListItemInput) -> Self { + Self { + id: value.id, + name: value.name, + numeric_value: value.numeric_value, + color: value.color, + } + } +} + +pub async fn handle( + command: &ListsCommand, + client: &C, + writer: &mut W, + pretty: bool, +) -> CliResult<()> { + handle_with_reader(command, client, writer, std::io::stdin(), pretty).await +} + +pub async fn handle_with_reader( + command: &ListsCommand, + client: &C, + writer: &mut W, + reader: R, + pretty: bool, +) -> CliResult<()> { + match command { + ListsCommand::SaveItem { list_id, body } => { + validate_positive_i32(*list_id, "list_id")?; + let input: SaveListItemInput = parse_body(body, reader)?; + let request: SaveListItemRequest = input.into(); + let response = client.save_list_item(*list_id, request).await?; + let output: SaveListItemResponseOutput = response.into(); + write_json(writer, &output, pretty)?; + } + ListsCommand::DeleteItem { list_id, item_id } => { + validate_positive_i32(*list_id, "list_id")?; + client.delete_list_item(*list_id, *item_id).await?; + write_json(writer, &SuccessResponse { ok: true }, pretty)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use onspring::{OnspringError, SaveListItemResponse}; + + use crate::cli::BodySource; + use crate::client::testing::MockClient; + use crate::error::CliError; + + // --- SaveItem tests --- + + #[tokio::test] + async fn handle_when_save_item_succeeds_it_should_write_save_response() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient { + save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }), + ..Default::default() + }; + + let command = ListsCommand::SaveItem { + list_id: 1, + body: BodySource { + json: Some( + r##"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e","name":"Item 1","numericValue":10.5,"color":"#ffffff"}"## + .to_string(), + ), + file: None, + stdin: false, + }, + }; + 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#"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e"}"# + ); + assert_eq!(*mock_client.save_list_item_list_id.lock().unwrap(), Some(1)); + let req = mock_client + .save_list_item_request + .lock() + .unwrap() + .clone() + .unwrap(); + assert_eq!(req.id, Some(item_uuid)); + assert_eq!(req.name, "Item 1"); + assert_eq!(req.numeric_value, Some(10.5)); + assert_eq!(req.color, Some("#ffffff".to_string())); + } + + #[tokio::test] + async fn handle_when_save_item_succeeds_pretty_it_should_write_pretty_json() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient { + save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }), + ..Default::default() + }; + + let command = ListsCommand::SaveItem { + list_id: 1, + body: BodySource { + json: Some(r#"{"name":"New Item"}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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", + " \"id\": \"d3b07384-d113-40e1-a20c-55c3c0a4e70e\"\n", + "}" + ); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_save_item_with_invalid_list_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = ListsCommand::SaveItem { + list_id: 0, + body: BodySource { + json: Some(r#"{"name":"Item"}"#.to_string()), + file: None, + stdin: false, + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("list_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_save_item_has_missing_body_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = ListsCommand::SaveItem { + list_id: 1, + body: BodySource::default(), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage( + "A request body is required. Provide --json, --file, or --stdin." + )) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_save_item_reads_from_stdin() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient { + save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }), + ..Default::default() + }; + + let command = ListsCommand::SaveItem { + list_id: 5, + body: BodySource { + json: None, + file: None, + stdin: true, + }, + }; + let mut buffer = Vec::new(); + let stdin_data = r#"{"name":"From Stdin","numericValue":2.0}"#; + + let result = handle_with_reader( + &command, + &mock_client, + &mut buffer, + stdin_data.as_bytes(), + false, + ) + .await; + + assert_eq!(result, Ok(())); + let written = String::from_utf8(buffer).unwrap(); + assert_eq!( + written.trim(), + r#"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e"}"# + ); + let req = mock_client + .save_list_item_request + .lock() + .unwrap() + .clone() + .unwrap(); + assert_eq!(req.name, "From Stdin"); + assert_eq!(req.numeric_value, Some(2.0)); + } + + #[tokio::test] + async fn handle_when_save_item_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + save_list_item_result: Err(OnspringError::Api { + status_code: 400, + message: "Invalid list item".to_string(), + }), + ..Default::default() + }; + + let command = ListsCommand::SaveItem { + list_id: 1, + body: BodySource { + json: Some(r#"{"name":"Item"}"#.to_string()), + file: None, + stdin: false, + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime( + "API request failed (400): Invalid list item" + )) + ); + assert!(buffer.is_empty()); + } + + // --- DeleteItem tests --- + + #[tokio::test] + async fn handle_when_delete_item_succeeds_it_should_write_success_response() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient { + delete_list_item_result: Ok(()), + ..Default::default() + }; + + let command = ListsCommand::DeleteItem { + list_id: 1, + item_id: item_uuid, + }; + 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#"{"ok":true}"#); + assert_eq!(*mock_client.delete_list_item_list_id.lock().unwrap(), Some(1)); + assert_eq!( + *mock_client.delete_list_item_item_id.lock().unwrap(), + Some(item_uuid) + ); + } + + #[tokio::test] + async fn handle_when_delete_item_succeeds_pretty_it_should_write_pretty_json() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient { + delete_list_item_result: Ok(()), + ..Default::default() + }; + + let command = ListsCommand::DeleteItem { + list_id: 1, + item_id: item_uuid, + }; + 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", " \"ok\": true\n", "}"); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_delete_item_with_invalid_list_id_it_should_return_usage_error() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient::default(); + + let command = ListsCommand::DeleteItem { + list_id: 0, + item_id: item_uuid, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("list_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_delete_item_fails_it_should_return_mapped_cli_error() { + let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(); + let mock_client = MockClient { + delete_list_item_result: Err(OnspringError::Api { + status_code: 404, + message: "List item not found".to_string(), + }), + ..Default::default() + }; + + let command = ListsCommand::DeleteItem { + list_id: 1, + item_id: item_uuid, + }; + 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): List item not found" + )) + ); + assert!(buffer.is_empty()); + } +} + diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3ed5b31..dbdcac1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,8 +1,9 @@ pub mod apps; pub mod fields; +pub mod files; +pub mod lists; pub mod ping; pub mod records; -pub mod files; use std::io::Write; @@ -21,5 +22,6 @@ pub async fn handle( Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await, 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, + } } diff --git a/src/output.rs b/src/output.rs index 78d4639..46c0b65 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, SaveRecordResponse, - ValueType, + ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, SaveListItemResponse, + SaveRecordResponse, ValueType, }; use serde::Serialize; use uuid::Uuid; @@ -251,6 +251,18 @@ impl From for CreatedWithIdResponseOutput { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveListItemResponseOutput { + pub id: Uuid, +} + +impl From for SaveListItemResponseOutput { + fn from(value: SaveListItemResponse) -> Self { + Self { id: value.id } + } +} + 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"))