diff --git a/src/cmd/bucket/create.rs b/src/cmd/bucket/create.rs index bfe8556..91cd622 100644 --- a/src/cmd/bucket/create.rs +++ b/src/cmd/bucket/create.rs @@ -24,23 +24,36 @@ pub(super) async fn create_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .unwrap() .clone() .pair()?; + + let is_json = ctx.json().unwrap_or(false); + let bucket_settings = parse_bucket_settings(args); let client: ReductClient = build_client(ctx, &alias_or_url).await?; + client .create_bucket(&bucket_name) .settings(bucket_settings) .send() .await?; - output!(ctx, "Bucket '{}' created", bucket_name); + if !is_json { + output!(ctx, "Bucket '{}' created", bucket_name); + } else { + output!(ctx, "{}", "{}"); + } + Ok(()) } #[cfg(test)] mod tests { + use super::*; - use crate::context::tests::{bucket, context}; + use crate::context::{ + tests::{bucket, context, MockOutput}, + ContextBuilder, + }; use reduct_rs::QuotaType; use rstest::*; @@ -156,4 +169,21 @@ mod tests { "Failed because of invalid block records" ); } + + #[rstest] + #[tokio::test] + async fn test_create_bucket_successfully_json(context: CliContext, #[future] bucket: String) { + let args = create_bucket_cmd() + .get_matches_from(vec!["create", format!("local/{}", bucket.await).as_str()]); + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + create_bucket(&ctx, &args).await.unwrap(); + + assert_eq!(ctx.stdout().history(), vec!["{}"]); + } } diff --git a/src/cmd/bucket/ls.rs b/src/cmd/bucket/ls.rs index 1c0ccd3..c11d0b5 100644 --- a/src/cmd/bucket/ls.rs +++ b/src/cmd/bucket/ls.rs @@ -12,6 +12,7 @@ use bytesize::ByteSize; use clap::ArgAction::SetTrue; use clap::{Arg, ArgMatches, Command}; use reduct_rs::BucketInfoList; +use serde::{Deserialize, Serialize}; use tabled::settings::Style; use tabled::{Table, Tabled}; @@ -35,24 +36,35 @@ pub(super) fn ls_bucket_cmd() -> Command { pub(super) async fn ls_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow::Result<()> { let alias_or_url = args.get_one::("ALIAS_OR_URL").unwrap(); + let is_json = ctx.json().unwrap_or(false); + let client = build_client(ctx, alias_or_url).await?; let bucket_list = client.bucket_list().await?; if args.get_flag("full") { - print_full_list(ctx, bucket_list); + print_full_list(ctx, bucket_list, is_json); } else { - print_list(ctx, bucket_list); + print_list(ctx, bucket_list, is_json); } Ok(()) } -fn print_list(ctx: &CliContext, bucket_list: BucketInfoList) { +fn print_list(ctx: &CliContext, bucket_list: BucketInfoList, is_json: bool) { + if is_json { + let buckets = bucket_list + .buckets + .iter() + .map(|bucket| bucket.name.as_str()) + .collect::>(); + output!(ctx, "{}", serde_json::to_string_pretty(&buckets).unwrap()); + return; + } for bucket in bucket_list.buckets { output!(ctx, "{}", bucket.name); } } -#[derive(Tabled)] +#[derive(Deserialize, Serialize, Tabled)] struct BucketRow { #[tabled(rename = "Name")] name: String, @@ -85,8 +97,11 @@ fn record_range_values(oldest: u64, latest: u64, is_empty: bool) -> (String, Str (oldest_value, latest_value) } -fn print_full_list(ctx: &CliContext, bucket_list: BucketInfoList) { +fn print_full_list(ctx: &CliContext, bucket_list: BucketInfoList, is_json: bool) { if bucket_list.buckets.is_empty() { + if is_json { + output!(ctx, "{}", "[]"); + } return; } @@ -115,6 +130,10 @@ fn print_full_list(ctx: &CliContext, bucket_list: BucketInfoList) { }) .collect::>(); + if is_json { + output!(ctx, "{}", serde_json::to_string_pretty(&rows).unwrap()); + return; + } let table = Table::new(rows).with(Style::markdown()).to_string(); output!(ctx, "{}", table); } @@ -122,7 +141,11 @@ fn print_full_list(ctx: &CliContext, bucket_list: BucketInfoList) { #[cfg(test)] mod tests { use super::*; - use crate::context::tests::{bucket, bucket2, context}; + use crate::context::{ + tests::{bucket, bucket2, context, MockOutput}, + ContextBuilder, + }; + use reduct_rs::{Bucket, ReductClient}; use rstest::rstest; #[rstest] @@ -210,4 +233,132 @@ mod tests { ] ); } + + #[rstest] + #[tokio::test] + async fn test_ls_bucket_full_json_with_buckets( + context: CliContext, + #[future] bucket: String, + #[future] bucket2: String, + ) { + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + let args = ls_bucket_cmd().get_matches_from(vec!["ls", "local", "--full"]); + let client = build_client(&ctx, "local").await.unwrap(); + + // Create buckets + let _ = create_test_bucket_with_entry(&bucket.await, &client).await; + let _ = create_test_bucket_with_entry(&bucket2.await, &client).await; + + // List buckets + ls_bucket(&ctx, &args).await.unwrap(); + + // We have $system bucket to consider too so len is 3. + let rows: Vec = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); + assert_eq!(rows.len(), 2); + } + + #[rstest] + #[tokio::test] + async fn test_ls_bucket_full_json_with_no_bucket( + context: CliContext, + #[future] bucket: String, + #[future] bucket2: String, + ) { + let _ = bucket.await; + let _ = bucket2.await; + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + let args = ls_bucket_cmd().get_matches_from(vec!["ls", "local", "--full"]); + + // List buckets + ls_bucket(&ctx, &args).await.unwrap(); + + // We have $system bucket to consider too so len is 1. + let history = ctx.stdout().history(); + let rows: Vec = serde_json::from_str(&history[0]).unwrap(); + + assert_eq!(rows.len(), 1); + } + + #[rstest] + #[tokio::test] + async fn test_ls_bucket_json_with_buckets( + context: CliContext, + #[future] bucket: String, + #[future] bucket2: String, + ) { + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + let args = ls_bucket_cmd().get_matches_from(vec!["ls", "local"]); + let client = build_client(&ctx, "local").await.unwrap(); + + // Create buckets + let bucket = create_test_bucket_with_entry(&bucket.await, &client).await; + let bucket2 = create_test_bucket_with_entry(&bucket2.await, &client).await; + + // List buckets + ls_bucket(&ctx, &args).await.unwrap(); + + // We have $system bucket to consider too so len is 3. + let rows: Vec = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); + + assert_eq!(rows.len(), 3); + assert!(rows.contains(&"$system".to_string())); + assert!(rows.contains(&bucket.name().to_string())); + assert!(rows.contains(&bucket2.name().to_string())); + } + + #[rstest] + #[tokio::test] + async fn test_ls_bucket_json_with_no_bucket( + context: CliContext, + #[future] bucket: String, + #[future] bucket2: String, + ) { + let _ = bucket.await; + let _ = bucket2.await; + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + let args = ls_bucket_cmd().get_matches_from(vec!["ls", "local"]); + + // List buckets + ls_bucket(&ctx, &args).await.unwrap(); + + // We have $system bucket to consider too so len is 1. + let rows: Vec = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); + + assert_eq!(rows.len(), 1); + assert!(rows.contains(&"$system".to_string())); + } + + async fn create_test_bucket_with_entry(bucket_name: &str, client: &ReductClient) -> Bucket { + let bucket = client.create_bucket(bucket_name).send().await.unwrap(); + bucket + .write_record("test") + .data("data") + .timestamp_us(0) + .send() + .await + .unwrap(); + bucket + } } diff --git a/src/cmd/bucket/rename.rs b/src/cmd/bucket/rename.rs index 0a03285..d6755e5 100644 --- a/src/cmd/bucket/rename.rs +++ b/src/cmd/bucket/rename.rs @@ -37,12 +37,17 @@ pub(super) async fn rename_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .pair()?; let new_name = args.get_one::("NEW_NAME").unwrap(); let entry_name = args.get_one::("only-entry"); + let is_json = ctx.json().unwrap_or(false); let client: ReductClient = build_client(ctx, &alias_or_url).await?; if let Some(entry_name) = entry_name { let bucket = client.get_bucket(&bucket_name).await?; bucket.rename_entry(entry_name, new_name).await?; - output!(ctx, "Entry '{}' renamed to '{}'", entry_name, new_name); + if !is_json { + output!(ctx, "Entry '{}' renamed to '{}'", entry_name, new_name); + } else { + output!(ctx, "{}", "{}"); + } } else { client .get_bucket(&bucket_name) @@ -50,7 +55,11 @@ pub(super) async fn rename_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .rename(new_name) .await?; - output!(ctx, "Bucket '{}' renamed to '{}'", bucket_name, new_name); + if !is_json { + output!(ctx, "Bucket '{}' renamed to '{}'", bucket_name, new_name); + } else { + output!(ctx, "{}", "{}"); + } } Ok(()) @@ -59,7 +68,10 @@ pub(super) async fn rename_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow #[cfg(test)] mod tests { use super::*; - use crate::context::tests::{bucket, context}; + use crate::context::{ + tests::{bucket, context, MockOutput}, + ContextBuilder, + }; use rstest::*; #[rstest] @@ -166,4 +178,40 @@ mod tests { "error: invalid value 'local' for ''\n\nFor more information, try '--help'.\n" ); } + + #[rstest] + #[tokio::test] + async fn test_rename_bucket_json(context: CliContext, #[future] bucket: String) { + let bucket_name = bucket.await; + let client = build_client(&context, "local").await.unwrap(); + client.create_bucket(&bucket_name).send().await.unwrap(); + + let args = rename_bucket_cmd().get_matches_from(vec![ + "rename", + format!("local/{}", bucket_name).as_str(), + "new_renamed_bucket", + ]); + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + rename_bucket(&ctx, &args).await.unwrap(); + + assert_eq!( + ctx.stdout().history().len(), + 1, + "JSON output contains one line" + ); + assert_eq!( + ctx.stdout().history()[0], + "{}".to_string(), + "JSON output is empty - {{}}" + ); + + let bucket = client.get_bucket("new_renamed_bucket").await.unwrap(); + bucket.remove().await.unwrap(); + } } diff --git a/src/context.rs b/src/context.rs index c0cd224..0b7a9b7 100644 --- a/src/context.rs +++ b/src/context.rs @@ -19,6 +19,7 @@ pub(crate) struct CliContext { timeout: Option, parallel: Option, ca_cert: Option, + json: Option, } impl CliContext { @@ -44,6 +45,10 @@ impl CliContext { pub(crate) fn ca_cert(&self) -> Option<&String> { self.ca_cert.as_ref() } + + pub(crate) fn json(&self) -> Option { + self.json + } } pub(crate) struct ContextBuilder { @@ -59,6 +64,7 @@ impl ContextBuilder { timeout: None, parallel: None, ca_cert: None, + json: None, }; config.config_path = match home_dir() { Some(path) => path @@ -107,6 +113,11 @@ impl ContextBuilder { self } + pub(crate) fn json(mut self, json: Option) -> Self { + self.config.json = json; + self + } + pub(crate) fn build(self) -> CliContext { self.config } diff --git a/src/main.rs b/src/main.rs index bbd98cc..8b2054c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use crate::cmd::alias::{alias_cmd, alias_handler}; use crate::cmd::attachment::{attachment_cmd, attachment_handler}; use crate::cmd::write::{write_handler, write_record_cmd}; use crate::context::ContextBuilder; +use serde_json::json; use std::time::Duration; use crate::cmd::bucket::{bucket_cmd, bucket_handler}; @@ -69,6 +70,15 @@ fn cli() -> Command { .required(false) .global(true), ) + .arg( + Arg::new("json") + .long("json") + .short('j') + .help("Print output in JSON format") + .required(false) + .action(SetTrue) + .global(true), + ) .subcommand(alias_cmd()) .subcommand(attachment_cmd()) .subcommand(server_cmd()) @@ -88,12 +98,14 @@ async fn main() -> anyhow::Result<()> { let timeout = matches.get_one::("timeout").copied(); let parallel = matches.get_one::("parallel").copied(); let ca_cert = matches.get_one::("ca-cert").cloned(); + let json = matches.get_one::("json").cloned(); let ctx = ContextBuilder::new() .ignore_ssl(ignore_ssl) .timeout(timeout.map(Duration::from_secs)) .parallel(parallel) .ca_cert(ca_cert) + .json(json) .build(); let result = match matches.subcommand() { @@ -111,10 +123,35 @@ async fn main() -> anyhow::Result<()> { _ => Ok(()), }; + let command = matches.subcommand().unwrap().0; + if let Err(err) = result { + // Do not output json if the command is "cp" + if matches.get_flag("json") && command != "cp" { + let json_error = print_json_error(&err); + eprintln!("{}", serde_json::to_string_pretty(&json_error)?); + std::process::exit(1); + } + eprintln!("{}", err.to_string().red().bold(),); std::process::exit(1); } Ok(()) } + +fn print_json_error(err: &anyhow::Error) -> serde_json::Value { + // Try to downcast to ReductError to get the status code + let (status_code, error_message) = + if let Some(reduct_err) = err.downcast_ref::() { + (reduct_err.status() as i32, reduct_err.message().to_string()) + } else { + // If not a ReductError, use 1 as unknown status + (1, err.to_string()) + }; + + json!({ + "status_code": status_code, + "error_message": error_message, + }) +}