diff --git a/Cargo.lock b/Cargo.lock index 1e0d014..eaaa32b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,6 +797,7 @@ dependencies = [ name = "onx" version = "0.0.0" dependencies = [ + "bytes", "chrono", "clap", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index 63f9bbc..8715946 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ license = "MIT" repository = "https://github.com/StevanFreeborn/onx" [dependencies] +bytes = "1.12.1" chrono = { version = "0.4.45", features = ["serde"] } clap = { version = "4.6.6", features = ["derive", "env"] } mime_guess = "2.0.5" diff --git a/src/cli.rs b/src/cli.rs index d36aa55..bc3f15f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -51,6 +51,12 @@ pub enum Command { #[command(subcommand)] command: RecordsCommand, }, + + #[command(about = "Perform operations against the files in the instance")] + Files { + #[command(subcommand)] + command: FilesCommand, + }, } #[derive(Debug, PartialEq, Subcommand)] @@ -60,19 +66,17 @@ pub enum AppsCommand { #[command(about = "Get information about an app")] Get { - #[arg(long = "app-id")] - #[arg(short = 'i')] - #[arg(alias = "app")] + #[arg(long = "app")] + #[arg(short = 'a')] #[arg(help = "App id to get")] app_id: i32, }, #[command(about = "Get information for a batch of apps")] BatchGet { - #[arg(long = "ids")] + #[arg(long = "apps")] #[arg(value_delimiter = ',')] - #[arg(short = 'i')] - #[arg(alias = "apps")] + #[arg(short = 'a')] #[arg(help = "Comma-separated list of app ids to get")] ids: Vec, }, @@ -82,9 +86,8 @@ pub enum AppsCommand { pub enum FieldsCommand { #[command(about = "Get information for a list of fields")] List { - #[arg(long = "app-id")] - #[arg(short = 'i')] - #[arg(alias = "app")] + #[arg(long = "app")] + #[arg(short = 'a')] #[arg(help = "App id whose fields will be retrieved")] app_id: i32, @@ -94,19 +97,17 @@ pub enum FieldsCommand { #[command(about = "Get information about a field")] Get { - #[arg(long = "field-id")] - #[arg(short = 'i')] - #[arg(alias = "field")] + #[arg(long = "field")] + #[arg(short = 'f')] #[arg(help = "Field id to get")] field_id: i32, }, #[command(about = "Get information for a batch of fields")] BatchGet { - #[arg(long = "ids")] + #[arg(long = "fields")] #[arg(value_delimiter = ',')] - #[arg(short = 'i')] - #[arg(alias = "fields")] + #[arg(short = 'f')] #[arg(help = "Comma-separated list of field ids to get")] ids: Vec, }, @@ -200,6 +201,107 @@ pub enum RecordsCommand { }, } +#[derive(Debug, PartialEq, Subcommand)] +pub enum FilesCommand { + #[command(about = "Get metadata info for a file")] + Info { + #[arg(long = "record")] + #[arg(short = 'r')] + #[arg(help = "Record id for record where the file is held")] + record_id: i32, + + #[arg(long = "field")] + #[arg(short = 'f')] + #[arg(help = "Field id for field where the file is held")] + field_id: i32, + + #[arg(long = "file")] + #[arg(short = 'l')] + #[arg(help = "The id of the file")] + file_id: i32, + }, + + #[command(about = "Get the content for a file")] + Get { + #[arg(long = "record")] + #[arg(short = 'r')] + #[arg(help = "Record id for record where the file is held")] + record_id: i32, + + #[arg(long = "field")] + #[arg(short = 'f')] + #[arg(help = "Field id for field where the file is held")] + field_id: i32, + + #[arg(long = "file")] + #[arg(short = 'l')] + #[arg(help = "The id of the file")] + file_id: i32, + + #[arg(long)] + #[arg(short = 'o')] + #[arg(help = "The output path for the file")] + output: Option, + }, + + Upload { + #[arg(long = "record")] + #[arg(short = 'r')] + #[arg(help = "Record id for record where the file should be uploaded")] + record_id: i32, + + #[arg(long = "field")] + #[arg(short = 'f')] + #[arg(help = "Field id for field where the file should be uploaded")] + field_id: i32, + + #[arg(long)] + #[arg(short = 'i')] + #[arg(help = "The input path of the file to be uploaded")] + input: Option, + + #[arg(long)] + #[arg(short = 's')] + #[arg(help = "Indicates whether file data should be read in from standard in")] + stdin: bool, + + #[arg(long = "name")] + #[arg(short = 'n')] + #[arg(help = "The name of the file to be uploaded")] + file_name: Option, + + #[arg(long = "type")] + #[arg(short = 't')] + #[arg(help = "The content type of the file to be uploaded")] + content_type: Option, + + #[arg(long)] + #[arg(help = "Notes that should be included in the file upload")] + notes: Option, + + #[arg(long)] + #[arg(help = "Modified date that should be included in the file upload as RFC3339 timestamp")] + modified_date: Option, + }, + + Delete { + #[arg(long = "record")] + #[arg(short = 'r')] + #[arg(help = "Record id for record where the file is held")] + record_id: i32, + + #[arg(long = "field")] + #[arg(short = 'f')] + #[arg(help = "Field id for field where the file is held")] + field_id: i32, + + #[arg(long = "file")] + #[arg(short = 'l')] + #[arg(help = "The id of the file")] + file_id: i32, + }, +} + #[derive(Debug, PartialEq, Clone, Args, Default)] pub struct PagingArgs { #[arg(long)] @@ -316,7 +418,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() { - let result = parse_cli_from(["test", "apps", "get", "--app-id", "123"]); + let result = parse_cli_from(["test", "apps", "get", "--app", "123"]); assert_eq!( result, @@ -333,7 +435,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() { - let result = parse_cli_from(["test", "apps", "get", "-i", "123"]); + let result = parse_cli_from(["test", "apps", "get", "-a", "123"]); assert_eq!( result, @@ -359,7 +461,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() { - let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "1,2,3"]); + let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "1,2,3"]); assert_eq!( result, @@ -395,7 +497,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error() { - let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "invalid"]); + let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "invalid"]); assert!(result.is_err()); let err = result.unwrap_err(); @@ -448,7 +550,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() { - let result = parse_cli_from(["test", "fields", "list", "--app-id", "10"]); + let result = parse_cli_from(["test", "fields", "list", "--app", "10"]); assert_eq!( result, @@ -472,7 +574,7 @@ mod tests { "test", "fields", "list", - "-i", + "-a", "10", "--page-number", "2", @@ -510,7 +612,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() { - let result = parse_cli_from(["test", "fields", "get", "--field-id", "123"]); + let result = parse_cli_from(["test", "fields", "get", "--field", "123"]); assert_eq!( result, @@ -527,7 +629,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() { - let result = parse_cli_from(["test", "fields", "get", "-i", "123"]); + let result = parse_cli_from(["test", "fields", "get", "-f", "123"]); assert_eq!( result, @@ -553,7 +655,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() { - let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "1,2,3"]); + let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "1,2,3"]); assert_eq!( result, @@ -589,7 +691,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error() { - let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "invalid"]); + let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "invalid"]); assert!(result.is_err()); let err = result.unwrap_err(); @@ -680,7 +782,17 @@ mod tests { #[test] fn parse_cli_from_when_called_with_records_get_it_should_return_cli() { let result = parse_cli_from([ - "test", "records", "get", "-a", "1", "-r", "10", "-f", "100", "-d", "formatted", + "test", + "records", + "get", + "-a", + "1", + "-r", + "10", + "-f", + "100", + "-d", + "formatted", ]); assert_eq!( @@ -703,9 +815,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() { - let result = parse_cli_from([ - "test", "records", "save", "--json", r#"{"appId":1}"#, - ]); + let result = parse_cli_from(["test", "records", "save", "--json", r#"{"appId":1}"#]); assert_eq!( result, @@ -748,9 +858,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() { - let result = parse_cli_from([ - "test", "records", "batch-get", "-f", "input.json", - ]); + let result = parse_cli_from(["test", "records", "batch-get", "-f", "input.json"]); assert_eq!( result, @@ -773,9 +881,7 @@ mod tests { #[test] fn parse_cli_from_when_called_with_records_query_it_should_return_cli() { - let result = parse_cli_from([ - "test", "records", "query", "--stdin", "-n", "1", "-s", "50", - ]); + let result = parse_cli_from(["test", "records", "query", "--stdin", "-n", "1", "-s", "50"]); assert_eq!( result, @@ -803,7 +909,11 @@ mod tests { #[test] fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() { let result = parse_cli_from([ - "test", "records", "batch-delete", "-j", r#"{"appId":1,"recordIds":[10]}"#, + "test", + "records", + "batch-delete", + "-j", + r#"{"appId":1,"recordIds":[10]}"#, ]); assert_eq!( @@ -833,4 +943,113 @@ mod tests { let err = result.unwrap_err(); assert_eq!(err.code, 2); } + + #[test] + fn parse_cli_from_when_called_with_files_info_it_should_return_cli() { + let result = parse_cli_from([ + "test", "files", "info", "--record", "1", "--field", "2", "--file", "3", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Files { + command: FilesCommand::Info { + record_id: 1, + field_id: 2, + file_id: 3, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_files_get_it_should_return_cli() { + let result = parse_cli_from([ + "test", "files", "get", "-r", "1", "-f", "2", "-l", "3", "-o", "out.bin", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Files { + command: FilesCommand::Get { + record_id: 1, + field_id: 2, + file_id: 3, + output: Some(std::path::PathBuf::from("out.bin")), + }, + }, + }) + ); + } + + #[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", + "2026-01-01T00:00:00Z", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Files { + command: FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(std::path::PathBuf::from("doc.pdf")), + stdin: false, + file_name: Some("renamed.pdf".to_string()), + content_type: Some("application/pdf".to_string()), + notes: Some("test notes".to_string()), + modified_date: Some("2026-01-01T00:00:00Z".to_string()), + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_files_delete_it_should_return_cli() { + let result = parse_cli_from([ + "test", "files", "delete", "--record", "10", "--field", "20", "--file", "30", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Files { + command: FilesCommand::Delete { + record_id: 10, + field_id: 20, + file_id: 30, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_files_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test", "files"]); + + 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 3093e34..02bda5a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,7 +1,8 @@ use onspring::{ - App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, DataFormat, Field, - OnspringClient, OnspringError, PagedResponse, PagingRequest, QueryRecordsRequest, Record, - SaveRecordRequest, SaveRecordResponse, + App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, + CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError, + PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveFileRequest, SaveRecordRequest, + SaveRecordResponse, }; pub trait OnspringRunner { @@ -80,6 +81,32 @@ pub trait OnspringRunner { &self, request: BatchDeleteRecordsRequest, ) -> impl std::future::Future> + Send; + + fn get_file_info( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> impl std::future::Future> + Send; + + fn get_file( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> impl std::future::Future> + Send; + + fn upload_file( + &self, + request: SaveFileRequest, + ) -> impl std::future::Future> + Send; + + fn delete_file( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> impl std::future::Future> + Send; } impl OnspringRunner for OnspringClient { @@ -170,6 +197,40 @@ impl OnspringRunner for OnspringClient { ) -> Result<(), OnspringError> { self.batch_delete_records(request).await } + + async fn get_file_info( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> Result { + self.get_file_info(record_id, field_id, file_id).await + } + + async fn get_file( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> Result { + self.get_file(record_id, field_id, file_id).await + } + + async fn upload_file( + &self, + request: SaveFileRequest, + ) -> Result { + self.upload_file(request).await + } + + async fn delete_file( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> Result<(), OnspringError> { + self.delete_file(record_id, field_id, file_id).await + } } #[cfg(test)] @@ -219,6 +280,25 @@ pub mod testing { pub query_records_request: Mutex>, pub query_records_paging: Mutex>>, pub batch_delete_records_request: Mutex>, + + pub get_file_info_result: Result, + pub get_file_result: Result, + pub upload_file_result: Result, + pub delete_file_result: Result<(), OnspringError>, + + pub get_file_info_record_id: Mutex>, + pub get_file_info_field_id: Mutex>, + pub get_file_info_file_id: Mutex>, + + pub get_file_record_id: Mutex>, + pub get_file_field_id: Mutex>, + pub get_file_file_id: Mutex>, + + pub upload_file_request: Mutex>, + + pub delete_file_record_id: Mutex>, + pub delete_file_field_id: Mutex>, + pub delete_file_file_id: Mutex>, } impl Default for MockClient { @@ -322,6 +402,38 @@ pub mod testing { query_records_request: Mutex::new(None), query_records_paging: Mutex::new(None), batch_delete_records_request: Mutex::new(None), + + get_file_info_result: Ok(FileInfo { + file_type: None, + content_type: None, + name: None, + created_date: None, + modified_date: None, + owner: None, + notes: None, + file_href: None, + }), + get_file_result: Ok(FileResponse { + content_type: None, + file_name: None, + data: bytes::Bytes::new(), + }), + upload_file_result: Ok(CreatedWithIdResponse { id: 1 }), + delete_file_result: Ok(()), + + get_file_info_record_id: Mutex::new(None), + get_file_info_field_id: Mutex::new(None), + get_file_info_file_id: Mutex::new(None), + + get_file_record_id: Mutex::new(None), + get_file_field_id: Mutex::new(None), + get_file_file_id: Mutex::new(None), + + upload_file_request: Mutex::new(None), + + delete_file_record_id: Mutex::new(None), + delete_file_field_id: Mutex::new(None), + delete_file_file_id: Mutex::new(None), } } } @@ -496,5 +608,61 @@ pub mod testing { Err(err) => Err(clone_onspring_error(err)), } } + + async fn get_file_info( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> Result { + *self.get_file_info_record_id.lock().unwrap() = Some(record_id); + *self.get_file_info_field_id.lock().unwrap() = Some(field_id); + *self.get_file_info_file_id.lock().unwrap() = Some(file_id); + match &self.get_file_info_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn get_file( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> Result { + *self.get_file_record_id.lock().unwrap() = Some(record_id); + *self.get_file_field_id.lock().unwrap() = Some(field_id); + *self.get_file_file_id.lock().unwrap() = Some(file_id); + match &self.get_file_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn upload_file( + &self, + request: SaveFileRequest, + ) -> Result { + *self.upload_file_request.lock().unwrap() = Some(request); + match &self.upload_file_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn delete_file( + &self, + record_id: i32, + field_id: i32, + file_id: i32, + ) -> Result<(), OnspringError> { + *self.delete_file_record_id.lock().unwrap() = Some(record_id); + *self.delete_file_field_id.lock().unwrap() = Some(field_id); + *self.delete_file_file_id.lock().unwrap() = Some(file_id); + match &self.delete_file_result { + Ok(()) => Ok(()), + Err(err) => Err(clone_onspring_error(err)), + } + } } } diff --git a/src/commands/files.rs b/src/commands/files.rs new file mode 100644 index 0000000..f6a1317 --- /dev/null +++ b/src/commands/files.rs @@ -0,0 +1,965 @@ +use std::io::{Read, Write}; + +use chrono::{DateTime, Utc}; +use onspring::SaveFileRequest; + +use crate::cli::FilesCommand; +use crate::client::OnspringRunner; +use crate::error::{CliError, CliResult}; +use crate::output::{CreatedWithIdResponseOutput, FileInfoOutput, SuccessResponse, write_json}; +use crate::validation::validate_positive_i32; + +pub async fn handle( + command: &FilesCommand, + 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: &FilesCommand, + client: &C, + writer: &mut W, + mut reader: R, + pretty: bool, +) -> CliResult<()> { + match command { + FilesCommand::Info { + record_id, + field_id, + file_id, + } => { + validate_positive_i32(*record_id, "record_id")?; + validate_positive_i32(*field_id, "field_id")?; + validate_positive_i32(*file_id, "file_id")?; + + let response = client + .get_file_info(*record_id, *field_id, *file_id) + .await?; + let output: FileInfoOutput = response.into(); + write_json(writer, &output, pretty)?; + } + FilesCommand::Get { + record_id, + field_id, + file_id, + output, + } => { + validate_positive_i32(*record_id, "record_id")?; + validate_positive_i32(*field_id, "field_id")?; + validate_positive_i32(*file_id, "file_id")?; + + let response = client.get_file(*record_id, *field_id, *file_id).await?; + + if let Some(output_path) = output { + std::fs::write(output_path, &response.data).map_err(|e| { + CliError::runtime(format!( + "Failed to write file to '{}': {e}", + output_path.display() + )) + })?; + } else { + writer + .write_all(&response.data) + .map_err(|_| CliError::runtime("Failed to write output"))?; + } + } + FilesCommand::Upload { + record_id, + field_id, + input, + stdin, + file_name, + content_type, + notes, + modified_date, + } => { + validate_positive_i32(*record_id, "record_id")?; + validate_positive_i32(*field_id, "field_id")?; + + if input.is_none() && !*stdin { + return Err(CliError::usage( + "A file source is required. Provide --input or --stdin.", + )); + } + + if input.is_some() && *stdin { + return Err(CliError::usage( + "Only one file source may be specified (--input or --stdin).", + )); + } + + let (file_bytes, resolved_file_name) = if let Some(path) = input { + let bytes = std::fs::read(path).map_err(|e| { + CliError::usage(format!("Failed to read file '{}': {e}", path.display())) + })?; + let default_name = path + .file_name() + .and_then(|n| n.to_str()) + .map(String::from); + let name = file_name.clone().or(default_name).ok_or_else(|| { + CliError::usage("A file name is required. Provide --name.") + })?; + (bytes, name) + } else { + let mut buffer = Vec::new(); + reader + .read_to_end(&mut buffer) + .map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?; + let name = file_name + .clone() + .ok_or_else(|| CliError::usage("A file name is required when reading from stdin. Provide --name."))?; + (buffer, name) + }; + + let resolved_content_type = match content_type { + Some(ct) => ct.clone(), + None => mime_guess::from_path(&resolved_file_name) + .first_or_octet_stream() + .to_string(), + }; + + let parsed_modified_date = match modified_date { + Some(date_str) => Some(DateTime::parse_from_rfc3339(date_str).map_err(|e| { + CliError::usage(format!( + "Invalid modified_date '{date_str}'. Expected RFC3339 format: {e}" + )) + })?.with_timezone(&Utc)), + None => None, + }; + + let request = SaveFileRequest { + record_id: *record_id, + field_id: *field_id, + notes: notes.clone(), + modified_date: parsed_modified_date, + file_name: resolved_file_name, + file_data: file_bytes, + content_type: resolved_content_type, + }; + + let response = client.upload_file(request).await?; + let output: CreatedWithIdResponseOutput = response.into(); + write_json(writer, &output, pretty)?; + } + FilesCommand::Delete { + record_id, + field_id, + file_id, + } => { + validate_positive_i32(*record_id, "record_id")?; + validate_positive_i32(*field_id, "field_id")?; + validate_positive_i32(*file_id, "file_id")?; + + client.delete_file(*record_id, *field_id, *file_id).await?; + write_json(writer, &SuccessResponse { ok: true }, pretty)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + use bytes::Bytes; + use chrono::TimeZone; + use onspring::{CreatedWithIdResponse, FileInfo, FileResponse, OnspringError}; + + use crate::client::testing::MockClient; + use crate::error::CliError; + + // --- Info tests --- + + #[tokio::test] + async fn handle_when_files_info_succeeds_it_should_write_file_info_output() { + let created = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); + let modified = Utc.with_ymd_and_hms(2026, 1, 16, 12, 0, 0).unwrap(); + + let mock_client = MockClient { + get_file_info_result: Ok(FileInfo { + file_type: Some("Attachment".to_string()), + content_type: Some("application/pdf".to_string()), + name: Some("test.pdf".to_string()), + created_date: Some(created), + modified_date: Some(modified), + owner: Some("User 1".to_string()), + notes: Some("Some notes".to_string()), + file_href: Some("https://api.onspring.com/files/3/file".to_string()), + }), + ..Default::default() + }; + + let command = FilesCommand::Info { + record_id: 1, + field_id: 2, + file_id: 3, + }; + 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#"{"type":"Attachment","contentType":"application/pdf","name":"test.pdf","createdDate":"2026-01-15T10:00:00Z","modifiedDate":"2026-01-16T12:00:00Z","owner":"User 1","notes":"Some notes","fileHref":"https://api.onspring.com/files/3/file"}"# + ); + assert_eq!(*mock_client.get_file_info_record_id.lock().unwrap(), Some(1)); + assert_eq!(*mock_client.get_file_info_field_id.lock().unwrap(), Some(2)); + assert_eq!(*mock_client.get_file_info_file_id.lock().unwrap(), Some(3)); + } + + #[tokio::test] + async fn handle_when_files_info_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + get_file_info_result: Ok(FileInfo { + file_type: Some("Attachment".to_string()), + content_type: Some("text/plain".to_string()), + name: Some("note.txt".to_string()), + created_date: None, + modified_date: None, + owner: None, + notes: None, + file_href: None, + }), + ..Default::default() + }; + + let command = FilesCommand::Info { + record_id: 1, + field_id: 2, + file_id: 3, + }; + 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", + " \"type\": \"Attachment\",\n", + " \"contentType\": \"text/plain\",\n", + " \"name\": \"note.txt\",\n", + " \"createdDate\": null,\n", + " \"modifiedDate\": null,\n", + " \"owner\": null,\n", + " \"notes\": null,\n", + " \"fileHref\": null\n", + "}" + ); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_files_info_with_invalid_record_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Info { + record_id: 0, + field_id: 2, + file_id: 3, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("record_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_files_info_with_invalid_field_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Info { + record_id: 1, + field_id: -1, + file_id: 3, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("field_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_files_info_with_invalid_file_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Info { + record_id: 1, + field_id: 2, + file_id: 0, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("file_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_files_info_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + get_file_info_result: Err(OnspringError::Api { + status_code: 404, + message: "File not found".to_string(), + }), + ..Default::default() + }; + + let command = FilesCommand::Info { + record_id: 1, + field_id: 2, + file_id: 3, + }; + 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): File not found")) + ); + assert!(buffer.is_empty()); + } + + // --- Get tests --- + + #[tokio::test] + async fn handle_when_files_get_without_output_flag_it_should_write_bytes_to_writer() { + let file_bytes = b"Hello, World!"; + let mock_client = MockClient { + get_file_result: Ok(FileResponse { + content_type: Some("text/plain".to_string()), + file_name: Some("hello.txt".to_string()), + data: Bytes::from_static(file_bytes), + }), + ..Default::default() + }; + + let command = FilesCommand::Get { + record_id: 10, + field_id: 20, + file_id: 30, + output: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + assert_eq!(buffer, file_bytes); + assert_eq!(*mock_client.get_file_record_id.lock().unwrap(), Some(10)); + assert_eq!(*mock_client.get_file_field_id.lock().unwrap(), Some(20)); + assert_eq!(*mock_client.get_file_file_id.lock().unwrap(), Some(30)); + } + + #[tokio::test] + async fn handle_when_files_get_with_output_flag_it_should_write_to_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("downloaded.bin"); + let file_bytes = vec![1, 2, 3, 4, 5]; + + let mock_client = MockClient { + get_file_result: Ok(FileResponse { + content_type: Some("application/octet-stream".to_string()), + file_name: Some("data.bin".to_string()), + data: Bytes::from(file_bytes.clone()), + }), + ..Default::default() + }; + + let command = FilesCommand::Get { + record_id: 1, + field_id: 2, + file_id: 3, + output: Some(file_path.clone()), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + assert!(buffer.is_empty()); + let read_back = std::fs::read(&file_path).unwrap(); + assert_eq!(read_back, file_bytes); + } + + #[tokio::test] + async fn handle_when_files_get_with_invalid_output_path_it_should_return_runtime_error() { + let mock_client = MockClient { + get_file_result: Ok(FileResponse { + content_type: None, + file_name: None, + data: Bytes::from_static(b"data"), + }), + ..Default::default() + }; + + let command = FilesCommand::Get { + record_id: 1, + field_id: 2, + file_id: 3, + output: Some(PathBuf::from("/nonexistent_dir_12345/sub/file.txt")), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 1); + assert!(err.message.contains("Failed to write file to")); + } + + #[tokio::test] + async fn handle_when_files_get_with_invalid_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Get { + record_id: 0, + field_id: 1, + file_id: 1, + output: None, + }; + let mut buffer = Vec::new(); + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("record_id must be greater than 0.")) + ); + + let command = FilesCommand::Get { + record_id: 1, + field_id: 0, + file_id: 1, + output: None, + }; + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("field_id must be greater than 0.")) + ); + + let command = FilesCommand::Get { + record_id: 1, + field_id: 1, + file_id: 0, + output: None, + }; + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("file_id must be greater than 0.")) + ); + } + + #[tokio::test] + async fn handle_when_files_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + get_file_result: Err(OnspringError::Api { + status_code: 500, + message: "Internal error".to_string(), + }), + ..Default::default() + }; + + let command = FilesCommand::Get { + record_id: 1, + field_id: 2, + file_id: 3, + output: 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 (500): Internal error" + )) + ); + } + + // --- Upload tests --- + + #[tokio::test] + async fn handle_when_files_upload_from_input_file_succeeds() { + let temp_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(temp_file.path(), b"Upload content").unwrap(); + + let mock_client = MockClient { + upload_file_result: Ok(CreatedWithIdResponse { id: 99 }), + ..Default::default() + }; + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(temp_file.path().to_path_buf()), + stdin: false, + file_name: Some("custom_name.txt".to_string()), + content_type: Some("text/custom".to_string()), + notes: Some("Upload note".to_string()), + modified_date: Some("2026-03-01T12:00:00Z".to_string()), + }; + 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":99}"#); + + let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap(); + assert_eq!(req.record_id, 1); + assert_eq!(req.field_id, 2); + assert_eq!(req.file_name, "custom_name.txt"); + assert_eq!(req.content_type, "text/custom"); + assert_eq!(req.file_data, b"Upload content"); + assert_eq!(req.notes, Some("Upload note".to_string())); + assert_eq!( + req.modified_date, + Some(Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap()) + ); + } + + #[tokio::test] + async fn handle_when_files_upload_succeeds_pretty_it_should_write_pretty_json() { + let temp_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(temp_file.path(), b"content").unwrap(); + + let mock_client = MockClient { + upload_file_result: Ok(CreatedWithIdResponse { id: 101 }), + ..Default::default() + }; + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(temp_file.path().to_path_buf()), + stdin: false, + file_name: Some("data.txt".to_string()), + content_type: None, + notes: None, + modified_date: 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", " \"id\": 101\n", "}"); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_files_upload_infers_file_name_and_content_type_from_input() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("report.csv"); + std::fs::write(&file_path, b"a,b,c\n1,2,3").unwrap(); + + let mock_client = MockClient { + upload_file_result: Ok(CreatedWithIdResponse { id: 50 }), + ..Default::default() + }; + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(file_path), + stdin: false, + file_name: None, + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap(); + assert_eq!(req.file_name, "report.csv"); + assert_eq!(req.content_type, "text/csv"); + } + + #[tokio::test] + async fn handle_when_files_upload_reads_from_stdin() { + let mock_client = MockClient { + upload_file_result: Ok(CreatedWithIdResponse { id: 75 }), + ..Default::default() + }; + + let command = FilesCommand::Upload { + record_id: 5, + field_id: 10, + input: None, + stdin: true, + file_name: Some("data.json".to_string()), + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + let stdin_bytes = b"{\"hello\":\"world\"}"; + + let result = handle_with_reader( + &command, + &mock_client, + &mut buffer, + &stdin_bytes[..], + false, + ) + .await; + + assert_eq!(result, Ok(())); + + let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap(); + assert_eq!(req.record_id, 5); + assert_eq!(req.field_id, 10); + assert_eq!(req.file_name, "data.json"); + assert_eq!(req.content_type, "application/json"); + assert_eq!(req.file_data, stdin_bytes); + } + + #[tokio::test] + async fn handle_when_files_upload_without_source_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: None, + stdin: false, + file_name: Some("file.txt".to_string()), + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage( + "A file source is required. Provide --input or --stdin." + )) + ); + } + + #[tokio::test] + async fn handle_when_files_upload_with_both_sources_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(PathBuf::from("test.txt")), + stdin: true, + file_name: Some("file.txt".to_string()), + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage( + "Only one file source may be specified (--input or --stdin)." + )) + ); + } + + #[tokio::test] + async fn handle_when_files_upload_input_file_not_found_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(PathBuf::from("nonexistent_file_12345.txt")), + stdin: false, + file_name: None, + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + assert!(err.message.contains("Failed to read file")); + } + + #[tokio::test] + async fn handle_when_files_upload_stdin_missing_name_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: None, + stdin: true, + file_name: None, + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + + let result = handle_with_reader( + &command, + &mock_client, + &mut buffer, + std::io::empty(), + false, + ) + .await; + + assert_eq!( + result, + Err(CliError::usage( + "A file name is required when reading from stdin. Provide --name." + )) + ); + } + + #[tokio::test] + async fn handle_when_files_upload_invalid_modified_date_it_should_return_usage_error() { + let temp_file = tempfile::NamedTempFile::new().unwrap(); + + let mock_client = MockClient::default(); + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(temp_file.path().to_path_buf()), + stdin: false, + file_name: None, + content_type: None, + notes: None, + modified_date: Some("not-a-date".to_string()), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + assert!(err.message.contains("Invalid modified_date")); + } + + #[tokio::test] + async fn handle_when_files_upload_with_invalid_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Upload { + record_id: 0, + field_id: 2, + input: Some(PathBuf::from("test.txt")), + stdin: false, + file_name: None, + content_type: None, + notes: None, + modified_date: None, + }; + let mut buffer = Vec::new(); + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("record_id must be greater than 0.")) + ); + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 0, + input: Some(PathBuf::from("test.txt")), + stdin: false, + file_name: None, + content_type: None, + notes: None, + modified_date: None, + }; + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("field_id must be greater than 0.")) + ); + } + + #[tokio::test] + async fn handle_when_files_upload_fails_it_should_return_mapped_cli_error() { + let temp_file = tempfile::NamedTempFile::new().unwrap(); + + let mock_client = MockClient { + upload_file_result: Err(OnspringError::Api { + status_code: 400, + message: "Invalid file type".to_string(), + }), + ..Default::default() + }; + + let command = FilesCommand::Upload { + record_id: 1, + field_id: 2, + input: Some(temp_file.path().to_path_buf()), + stdin: false, + file_name: None, + content_type: None, + notes: None, + modified_date: 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 (400): Invalid file type" + )) + ); + } + + // --- Delete tests --- + + #[tokio::test] + async fn handle_when_files_delete_succeeds_it_should_write_success_response() { + let mock_client = MockClient { + delete_file_result: Ok(()), + ..Default::default() + }; + + let command = FilesCommand::Delete { + record_id: 1, + field_id: 2, + file_id: 3, + }; + 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_file_record_id.lock().unwrap(), Some(1)); + assert_eq!(*mock_client.delete_file_field_id.lock().unwrap(), Some(2)); + assert_eq!(*mock_client.delete_file_file_id.lock().unwrap(), Some(3)); + } + + #[tokio::test] + async fn handle_when_files_delete_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + delete_file_result: Ok(()), + ..Default::default() + }; + + let command = FilesCommand::Delete { + record_id: 1, + field_id: 2, + file_id: 3, + }; + 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_files_delete_with_invalid_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FilesCommand::Delete { + record_id: 0, + field_id: 2, + file_id: 3, + }; + let mut buffer = Vec::new(); + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("record_id must be greater than 0.")) + ); + + let command = FilesCommand::Delete { + record_id: 1, + field_id: 0, + file_id: 3, + }; + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("field_id must be greater than 0.")) + ); + + let command = FilesCommand::Delete { + record_id: 1, + field_id: 2, + file_id: -1, + }; + assert_eq!( + handle(&command, &mock_client, &mut buffer, false).await, + Err(CliError::usage("file_id must be greater than 0.")) + ); + } + + #[tokio::test] + async fn handle_when_files_delete_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + delete_file_result: Err(OnspringError::Api { + status_code: 404, + message: "File not found".to_string(), + }), + ..Default::default() + }; + + let command = FilesCommand::Delete { + record_id: 1, + field_id: 2, + file_id: 3, + }; + 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): File not found")) + ); + } +} + diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f0879fe..3ed5b31 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod fields; pub mod ping; pub mod records; +pub mod files; use std::io::Write; @@ -18,6 +19,7 @@ pub async fn handle( Command::Ping => ping::handle(client, writer, cli.pretty).await, Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await, Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await, - Command::Records { command } => records::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, +} } diff --git a/src/output.rs b/src/output.rs index f3d5ca0..78d4639 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,8 +1,10 @@ use std::io::Write; +use chrono::{DateTime, Utc}; use onspring::{ - App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse, - Record, RecordFieldValue, SaveRecordResponse, ValueType, + App, CollectionResponse, CreatedWithIdResponse, Field, FileInfo, FormulaOutputType, + ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, SaveRecordResponse, + ValueType, }; use serde::Serialize; use uuid::Uuid; @@ -208,6 +210,47 @@ impl From for SaveRecordResponseOutput { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileInfoOutput { + #[serde(rename = "type")] + pub file_type: Option, + pub content_type: Option, + pub name: Option, + pub created_date: Option>, + pub modified_date: Option>, + pub owner: Option, + pub notes: Option, + pub file_href: Option, +} + +impl From for FileInfoOutput { + fn from(value: FileInfo) -> Self { + Self { + file_type: value.file_type, + content_type: value.content_type, + name: value.name, + created_date: value.created_date, + modified_date: value.modified_date, + owner: value.owner, + notes: value.notes, + file_href: value.file_href, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatedWithIdResponseOutput { + pub id: i32, +} + +impl From for CreatedWithIdResponseOutput { + fn from(value: CreatedWithIdResponse) -> 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")) diff --git a/src/validation.rs b/src/validation.rs index 63c4c39..a84f847 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -58,14 +58,10 @@ pub fn validate_ids(ids: &[i32], name: &str, max_len: Option) -> CliResul } pub fn read_body(source: &BodySource, mut reader: R) -> CliResult { - let count = [ - source.json.is_some(), - source.file.is_some(), - source.stdin, - ] - .into_iter() - .filter(|&b| b) - .count(); + let count = [source.json.is_some(), source.file.is_some(), source.stdin] + .into_iter() + .filter(|&b| b) + .count(); if count == 0 { return Err(CliError::usage( @@ -84,13 +80,8 @@ pub fn read_body(source: &BodySource, mut reader: R) -> CliResult(source: &BodySource, mut reader: R) -> CliResult( - source: &BodySource, - reader: R, -) -> CliResult { +pub fn parse_body(source: &BodySource, reader: R) -> CliResult { let content = read_body(source, reader)?; serde_json::from_str(&content) .map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}"))) @@ -261,9 +249,7 @@ mod tests { let ids = vec![1; 101]; assert_eq!( validate_ids(&ids, "ids", Some(100)), - Err(CliError::usage( - "ids cannot contain more than 100 values." - )) + Err(CliError::usage("ids cannot contain more than 100 values.")) ); }