feat: implement lists commands

This commit is contained in:
Stevan Freeborn committed 2026-10-01 09:53:20 -05:00
1 parent 213ed0b834
commit c83708a727
5 files changed
+574 -8

No files matched your search

+115 -2
View File
@@ -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<PathBuf>,
},
#[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<String>,
},
#[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);
}
}
+69 -2
View File
@@ -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<Output = Result<(), OnspringError>> + Send;
@@ -107,6 +108,18 @@ pub trait OnspringRunner {
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn save_list_item(
&self,
list_id: i32,
request: SaveListItemRequest,
) -> impl std::future::Future<Output = Result<SaveListItemResponse, OnspringError>> + Send;
fn delete_list_item(
&self,
list_id: i32,
item_id: Uuid,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + 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<SaveListItemResponse, OnspringError> {
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<Option<i32>>,
pub delete_file_field_id: Mutex<Option<i32>>,
pub delete_file_file_id: Mutex<Option<i32>>,
pub save_list_item_result: Result<SaveListItemResponse, OnspringError>,
pub delete_list_item_result: Result<(), OnspringError>,
pub save_list_item_list_id: Mutex<Option<i32>>,
pub save_list_item_request: Mutex<Option<SaveListItemRequest>>,
pub delete_list_item_list_id: Mutex<Option<i32>>,
pub delete_list_item_item_id: Mutex<Option<Uuid>>,
}
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<SaveListItemResponse, OnspringError> {
*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)),
}
}
}
}
+372
View File
@@ -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<Uuid>,
pub name: String,
pub numeric_value: Option<f64>,
pub color: Option<String>,
}
impl From<SaveListItemInput> 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<C: OnspringRunner, W: Write>(
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<C: OnspringRunner, W: Write, R: Read>(
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());
}
}
+4 -2
View File
@@ -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<C: OnspringRunner, W: Write>(
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,
}
}
+14 -2
View File
@@ -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<CreatedWithIdResponse> for CreatedWithIdResponseOutput {
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveListItemResponseOutput {
pub id: Uuid,
}
impl From<SaveListItemResponse> for SaveListItemResponseOutput {
fn from(value: SaveListItemResponse) -> Self {
Self { id: value.id }
}
}
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
if pretty {
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))