From 338d55f0edb7942d530d25ad649e0fb3e696e6c6 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Wed, 22 Jul 2026 09:32:59 +0530 Subject: [PATCH 01/13] Print json when using global --json Signed-off-by: Malhar Vora --- src/cmd/bucket/create.rs | 13 ++++++++++++- src/main.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/cmd/bucket/create.rs b/src/cmd/bucket/create.rs index bfe8556..6d75101 100644 --- a/src/cmd/bucket/create.rs +++ b/src/cmd/bucket/create.rs @@ -12,6 +12,7 @@ use crate::io::std::output; use crate::parse::Resource; use clap::{ArgMatches, Command}; use reduct_rs::ReductClient; +use serde_json::json; pub(super) fn create_bucket_cmd() -> Command { let cmd = Command::new("create").about("Create a bucket"); @@ -24,16 +25,26 @@ pub(super) async fn create_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .unwrap() .clone() .pair()?; + let is_json = args.get_flag("json"); let bucket_settings = parse_bucket_settings(args); let client: ReductClient = build_client(ctx, &alias_or_url).await?; + // .map_err(|err| anyhow::anyhow!(get_json_error(err.to_string(), is_json)))?; + client .create_bucket(&bucket_name) .settings(bucket_settings) .send() .await?; + // .map_err(|err| anyhow::anyhow!(get_json_error(err.to_string(), is_json)))?; + + if !is_json { + output!(ctx, "Bucket '{}' created", bucket_name); + } else { + ctx.stdout() + .print(serde_json::to_string_pretty(&json!({})).unwrap().as_str()); + } - output!(ctx, "Bucket '{}' created", bucket_name); Ok(()) } diff --git a/src/main.rs b/src/main.rs index bbd98cc..a975d69 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()) @@ -111,7 +121,19 @@ 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 = json!({ + "status": "error", + "error_message": err.to_string(), + }); + eprintln!("{}", serde_json::to_string_pretty(&json_error).unwrap()); + std::process::exit(1); + } + eprintln!("{}", err.to_string().red().bold(),); std::process::exit(1); } From 2c38bb56d886a8c1326b50ec83ba0bdb3d30047e Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Wed, 22 Jul 2026 20:04:59 +0530 Subject: [PATCH 02/13] Improvement: Return status code and message Signed-off-by: Malhar Vora --- src/main.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index a975d69..baa5862 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,11 +126,8 @@ async fn main() -> anyhow::Result<()> { if let Err(err) = result { // Do not output json if the command is "cp" if matches.get_flag("json") && command != "cp" { - let json_error = json!({ - "status": "error", - "error_message": err.to_string(), - }); - eprintln!("{}", serde_json::to_string_pretty(&json_error).unwrap()); + let json_error = print_json_error(&err); + eprintln!("{}", serde_json::to_string_pretty(&json_error)?); std::process::exit(1); } @@ -140,3 +137,19 @@ async fn main() -> anyhow::Result<()> { 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, + }) +} From e635baaafa712f187288e2d05bc9ed077d65a12d Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Wed, 22 Jul 2026 22:25:56 +0530 Subject: [PATCH 03/13] Update "create bucket" to use global flag Signed-off-by: Malhar Vora --- src/cmd/bucket/create.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cmd/bucket/create.rs b/src/cmd/bucket/create.rs index 6d75101..ce3fd24 100644 --- a/src/cmd/bucket/create.rs +++ b/src/cmd/bucket/create.rs @@ -25,18 +25,23 @@ pub(super) async fn create_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .unwrap() .clone() .pair()?; - let is_json = args.get_flag("json"); + + let is_json = args + .try_get_one::("json") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let bucket_settings = parse_bucket_settings(args); let client: ReductClient = build_client(ctx, &alias_or_url).await?; - // .map_err(|err| anyhow::anyhow!(get_json_error(err.to_string(), is_json)))?; client .create_bucket(&bucket_name) .settings(bucket_settings) .send() .await?; - // .map_err(|err| anyhow::anyhow!(get_json_error(err.to_string(), is_json)))?; if !is_json { output!(ctx, "Bucket '{}' created", bucket_name); From 6d38d33510595bde221551363fea9aea8b75a449 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Wed, 29 Jul 2026 14:04:38 +0530 Subject: [PATCH 04/13] Some corrections and tests Signed-off-by: Malhar Vora --- src/cmd/bucket/create.rs | 34 ++++++++++++++++++++++++---------- src/context.rs | 11 +++++++++++ src/main.rs | 2 ++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/cmd/bucket/create.rs b/src/cmd/bucket/create.rs index ce3fd24..0604326 100644 --- a/src/cmd/bucket/create.rs +++ b/src/cmd/bucket/create.rs @@ -12,7 +12,6 @@ use crate::io::std::output; use crate::parse::Resource; use clap::{ArgMatches, Command}; use reduct_rs::ReductClient; -use serde_json::json; pub(super) fn create_bucket_cmd() -> Command { let cmd = Command::new("create").about("Create a bucket"); @@ -26,12 +25,7 @@ pub(super) async fn create_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .clone() .pair()?; - let is_json = args - .try_get_one::("json") - .ok() - .flatten() - .copied() - .unwrap_or(false); + let is_json = ctx.json().unwrap_or(false); let bucket_settings = parse_bucket_settings(args); @@ -46,8 +40,7 @@ pub(super) async fn create_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow if !is_json { output!(ctx, "Bucket '{}' created", bucket_name); } else { - ctx.stdout() - .print(serde_json::to_string_pretty(&json!({})).unwrap().as_str()); + output!(ctx, "{}", "{}"); } Ok(()) @@ -55,8 +48,12 @@ pub(super) async fn create_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 reduct_rs::QuotaType; use rstest::*; @@ -172,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() + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + create_bucket(&ctx, &args).await.unwrap(); + + assert_eq!(ctx.stdout().history(), vec![format!("{{}}")]); + } } 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 baa5862..8b2054c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,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() { From 2d205842ece84a691983bb485061a4df64caf056 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Fri, 31 Jul 2026 16:55:59 +0530 Subject: [PATCH 05/13] cargo fmt Signed-off-by: Malhar Vora --- src/cmd/bucket/create.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cmd/bucket/create.rs b/src/cmd/bucket/create.rs index 0604326..c0dd3f0 100644 --- a/src/cmd/bucket/create.rs +++ b/src/cmd/bucket/create.rs @@ -173,7 +173,6 @@ mod tests { #[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()]); From 41e198872678ef869e4a72a452c3251c31ebb546 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Fri, 31 Jul 2026 18:29:57 +0530 Subject: [PATCH 06/13] Use config_path from existing context in failing UT Signed-off-by: Malhar Vora --- src/cmd/bucket/create.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cmd/bucket/create.rs b/src/cmd/bucket/create.rs index c0dd3f0..91cd622 100644 --- a/src/cmd/bucket/create.rs +++ b/src/cmd/bucket/create.rs @@ -172,17 +172,18 @@ mod tests { #[rstest] #[tokio::test] - async fn test_create_bucket_successfully_json(_context: CliContext, #[future] bucket: String) { + 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![format!("{{}}")]); + assert_eq!(ctx.stdout().history(), vec!["{}"]); } } From c23d7856634f6a6e37068806f18b31a6121dcb21 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Fri, 31 Jul 2026 19:20:12 +0530 Subject: [PATCH 07/13] Add json handling to "ls" sub-command with unit tests Signed-off-by: Malhar Vora --- src/cmd/bucket/ls.rs | 171 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 6 deletions(-) diff --git a/src/cmd/bucket/ls.rs b/src/cmd/bucket/ls.rs index 1c0ccd3..2260927 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,140 @@ 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 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().join("\n")).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].name, "$system"); + assert_eq!(rows[1].name, bucket.name()); + assert_eq!(rows[2].name, bucket2.name()); + } + + #[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!(history.len(), 1); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "$system"); + } + + #[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().join("\n")).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 history = ctx.stdout().history(); + let rows: Vec = serde_json::from_str(&history[0]).unwrap(); + + assert_eq!(history.len(), 1); + 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 + } } From 3fa02bfc3efb8790bcb9ae40d0811c10528e1c0a Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Fri, 31 Jul 2026 23:07:31 +0530 Subject: [PATCH 08/13] Handle "--json" flag in "rename" command with UTs Signed-off-by: Malhar Vora --- src/cmd/bucket/rename.rs | 54 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/cmd/bucket/rename.rs b/src/cmd/bucket/rename.rs index 0a03285..18c4e80 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_bucket").await.unwrap(); + bucket.remove().await.unwrap(); + } } From c59c3c28d1073cdd44657a634f4175c3c5dbf903 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Sat, 1 Aug 2026 10:24:39 +0530 Subject: [PATCH 09/13] Some corrections in UTs Signed-off-by: Malhar Vora --- src/cmd/bucket/ls.rs | 49 +++++++++++++++++----------------------- src/cmd/bucket/rename.rs | 2 +- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/cmd/bucket/ls.rs b/src/cmd/bucket/ls.rs index 2260927..d25498e 100644 --- a/src/cmd/bucket/ls.rs +++ b/src/cmd/bucket/ls.rs @@ -140,6 +140,7 @@ fn print_full_list(ctx: &CliContext, bucket_list: BucketInfoList, is_json: bool) #[cfg(test)] mod tests { + use super::*; use crate::context::{ tests::{bucket, bucket2, context, MockOutput}, @@ -241,6 +242,9 @@ mod tests { #[future] bucket: String, #[future] bucket2: String, ) { + let bucket = bucket.await; + let bucket2 = bucket2.await; + let ctx = ContextBuilder::new() .config_path(context.config_path()) .json(Some(true)) @@ -251,19 +255,16 @@ mod tests { 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; + let _ = create_test_bucket_with_entry(&bucket, &client).await; + let _ = create_test_bucket_with_entry(&bucket2, &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().join("\n")).unwrap(); - assert_eq!(rows.len(), 3); - assert_eq!(rows[0].name, "$system"); - assert_eq!(rows[1].name, bucket.name()); - assert_eq!(rows[2].name, bucket2.name()); + let rows: Vec = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); + + assert!(rows.iter().any(|row| row.name == bucket)); + assert!(rows.iter().any(|row| row.name == bucket2)); } #[rstest] @@ -273,8 +274,8 @@ mod tests { #[future] bucket: String, #[future] bucket2: String, ) { - let _ = bucket.await; - let _ = bucket2.await; + let bucket = bucket.await; + let bucket2 = bucket2.await; let ctx = ContextBuilder::new() .config_path(context.config_path()) @@ -287,13 +288,10 @@ mod tests { // 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(); + let rows: Vec = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); - assert_eq!(history.len(), 1); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].name, "$system"); + assert_eq!(rows.iter().any(|row| row.name == bucket), false); + assert_eq!(rows.iter().any(|row| row.name == bucket2), false); } #[rstest] @@ -319,11 +317,8 @@ mod tests { // 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().join("\n")).unwrap(); + 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())); } @@ -335,8 +330,8 @@ mod tests { #[future] bucket: String, #[future] bucket2: String, ) { - let _ = bucket.await; - let _ = bucket2.await; + let bucket = bucket.await; + let bucket2 = bucket2.await; let ctx = ContextBuilder::new() .config_path(context.config_path()) @@ -350,12 +345,10 @@ mod tests { 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(); + let rows: Vec = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); - assert_eq!(history.len(), 1); - assert_eq!(rows.len(), 1); - assert!(rows.contains(&"$system".to_string())); + assert_eq!(rows.contains(&bucket), false); + assert_eq!(rows.contains(&bucket2), false); } async fn create_test_bucket_with_entry(bucket_name: &str, client: &ReductClient) -> Bucket { diff --git a/src/cmd/bucket/rename.rs b/src/cmd/bucket/rename.rs index 18c4e80..d6755e5 100644 --- a/src/cmd/bucket/rename.rs +++ b/src/cmd/bucket/rename.rs @@ -211,7 +211,7 @@ mod tests { "JSON output is empty - {{}}" ); - let bucket = client.get_bucket("new_bucket").await.unwrap(); + let bucket = client.get_bucket("new_renamed_bucket").await.unwrap(); bucket.remove().await.unwrap(); } } From 874a42660b6de0d27f9f989e7a74553ea260a9a2 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Sat, 1 Aug 2026 18:24:37 +0530 Subject: [PATCH 10/13] Handle "--json" flag in "rm" command with UTs Signed-off-by: Malhar Vora --- src/cmd/bucket/rm.rs | 112 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 9 deletions(-) diff --git a/src/cmd/bucket/rm.rs b/src/cmd/bucket/rm.rs index f86bb72..0ef5cc0 100644 --- a/src/cmd/bucket/rm.rs +++ b/src/cmd/bucket/rm.rs @@ -51,11 +51,20 @@ pub(super) async fn rm_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow::Re .unwrap_or_default() .map(|s| s.to_string()) .collect::>(); + let is_json = ctx.json().unwrap_or(false); if !only_entries.is_empty() { - remove_entries(ctx, args, &alias_or_url, &bucket_name, only_entries).await?; + remove_entries( + ctx, + args, + &alias_or_url, + &bucket_name, + only_entries, + is_json, + ) + .await?; } else { - remove_entire_bucket(ctx, args, &alias_or_url, &bucket_name).await?; + remove_entire_bucket(ctx, args, &alias_or_url, &bucket_name, is_json).await?; } Ok(()) } @@ -65,6 +74,7 @@ async fn remove_entire_bucket( args: &ArgMatches, alias_or_url: &String, bucket_name: &String, + is_json: bool, ) -> anyhow::Result<()> { let yes = args.get_flag("yes"); let confirm = if !yes { @@ -84,9 +94,17 @@ async fn remove_entire_bucket( let client: ReductClient = build_client(ctx, &alias_or_url).await?; client.get_bucket(&bucket_name).await?.remove().await?; - output!(ctx, "Bucket '{}' deleted", bucket_name); + if !is_json { + output!(ctx, "Bucket '{}' deleted", bucket_name); + } else { + output!(ctx, "{}", "{}"); + } } else { - output!(ctx, "Bucket '{}' not deleted", bucket_name); + if !is_json { + output!(ctx, "Bucket '{}' not deleted", bucket_name); + } else { + output!(ctx, "{}", "{}"); + } } Ok(()) } @@ -97,6 +115,7 @@ async fn remove_entries( alias_or_url: &String, bucket_name: &String, only_entries: Vec, + is_json: bool, ) -> anyhow::Result<()> { let client: ReductClient = build_client(ctx, &alias_or_url).await?; let bucket = client.get_bucket(&bucket_name).await?; @@ -107,7 +126,11 @@ async fn remove_entries( .map(|entry| entry.name.clone()) .collect::>(); if entries.is_empty() { - output!(ctx, "No entries found to delete"); + if !is_json { + output!(ctx, "No entries found to delete"); + } else { + output!(ctx, "{}", "{}"); + } return Ok(()); } @@ -129,10 +152,17 @@ async fn remove_entries( for entry in &entries { bucket.remove_entry(entry).await?; } - - output!(ctx, "Entries {:?} deleted", entries); + if !is_json { + output!(ctx, "Entries {:?} deleted", entries); + } else { + output!(ctx, "{}", "{}"); + } } else { - output!(ctx, "Entries {:?} not deleted", entries); + if !is_json { + output!(ctx, "Entries {:?} not deleted", entries); + } else { + output!(ctx, "{}", "{}"); + } } Ok(()) } @@ -140,7 +170,10 @@ async fn remove_entries( #[cfg(test)] mod tests { use super::*; - use crate::context::tests::{bucket, context}; + use crate::context::{ + tests::{bucket, context, MockOutput}, + ContextBuilder, + }; use bytes::Bytes; use reduct_rs::ErrorCode; use rstest::*; @@ -247,4 +280,65 @@ mod tests { ErrorCode::NotFound ); } + + #[rstest] + #[tokio::test] + async fn test_rm_bucket_json(context: CliContext, #[future] bucket: String) { + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + let bucket_name = bucket.await; + let client = build_client(&ctx, "local").await.unwrap(); + client.create_bucket(&bucket_name).send().await.unwrap(); + + let args = rm_bucket_cmd().get_matches_from(vec![ + "rm", + format!("local/{}", bucket_name).as_str(), + "--yes", + ]); + + rm_bucket(&ctx, &args).await.unwrap(); + + wait_for_bucket_removed(&client, &bucket_name).await; + + assert_eq!(ctx.stdout().history(), vec!["{}"]); + } + + #[rstest] + #[tokio::test] + async fn test_rm_bucket_only_entries_json(context: CliContext, #[future] bucket: String) { + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + let bucket_name = bucket.await; + let client = build_client(&ctx, "local").await.unwrap(); + let bucket = client.create_bucket(&bucket_name).send().await.unwrap(); + + bucket + .write_record("test") + .data(Bytes::from_static(b"test")) + .send() + .await + .unwrap(); + + let args = rm_bucket_cmd().get_matches_from(vec![ + "rm", + format!("local/{}", bucket_name).as_str(), + "--yes", + "--only-entries", + "test", + ]); + + rm_bucket(&ctx, &args).await.unwrap(); + + wait_for_empty_entries(&client, &bucket_name).await; + + assert_eq!(ctx.stdout().history(), vec!["{}"]); + } } From 3d6d42ab1ed9b1a3d5aef86b0a265e5376684a39 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Sat, 1 Aug 2026 18:59:52 +0530 Subject: [PATCH 11/13] Handle "--json" flag in "update" command with UTs Signed-off-by: Malhar Vora --- src/cmd/bucket/update.rs | 43 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/cmd/bucket/update.rs b/src/cmd/bucket/update.rs index f38666d..aafde70 100644 --- a/src/cmd/bucket/update.rs +++ b/src/cmd/bucket/update.rs @@ -23,6 +23,7 @@ pub(super) async fn update_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?; @@ -32,14 +33,21 @@ pub(super) async fn update_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow .set_settings(bucket_settings) .await?; - output!(ctx, "Bucket '{}' updated", bucket_name); + if !is_json { + output!(ctx, "Bucket '{}' updated", 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::*; @@ -155,4 +163,35 @@ mod tests { "Failed because of invalid block records" ); } + + #[rstest] + #[tokio::test] + async fn test_update_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 = update_bucket_cmd().get_matches_from(vec![ + "update", + format!("local/{}", bucket_name).as_str(), + "--quota-type", + "FIFO", + "--quota-size", + "1GB", + "--block-size", + "32MB", + "--block-records", + "100", + ]); + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + update_bucket(&ctx, &args).await.unwrap(); + + assert_eq!(ctx.stdout().history(), vec!["{}"]); + } } From 3bbe885ab881bdb9a1b87ce569e6cd9bdaa8a718 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Sun, 2 Aug 2026 09:49:18 +0530 Subject: [PATCH 12/13] Handle "--json" flag in "show" command with UTs Signed-off-by: Malhar Vora --- src/cmd/bucket/show.rs | 192 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 175 insertions(+), 17 deletions(-) diff --git a/src/cmd/bucket/show.rs b/src/cmd/bucket/show.rs index 983c64c..7376db2 100644 --- a/src/cmd/bucket/show.rs +++ b/src/cmd/bucket/show.rs @@ -15,6 +15,7 @@ use bytesize::ByteSize; use clap::ArgAction::SetTrue; use clap::{Arg, ArgMatches, Command}; use reduct_rs::{EntryInfo, FullBucketInfo, ReductClient}; +use serde::Serialize; use tabled::{settings::Style, Table, Tabled}; pub(super) fn show_bucket_cmd() -> Command { @@ -36,7 +37,7 @@ pub(super) fn show_bucket_cmd() -> Command { ) } -#[derive(Tabled)] +#[derive(Clone, Serialize, Tabled)] struct EntryTable { #[tabled(rename = "Name")] name: String, @@ -76,14 +77,15 @@ pub(super) async fn show_bucket(ctx: &CliContext, args: &ArgMatches) -> anyhow:: .unwrap() .clone() .pair()?; + let is_json = ctx.json().unwrap_or(false); let client: ReductClient = build_client(ctx, &alias_or_url).await?; let bucket = client.get_bucket(&bucket_name).await?.full_info().await?; if args.get_flag("full") { - print_full_bucket(ctx, bucket)?; + print_full_bucket(ctx, bucket, is_json)?; } else { - print_bucket(ctx, bucket)?; + print_bucket(ctx, bucket, is_json)?; } Ok(()) @@ -96,13 +98,40 @@ fn record_range_cells_compact(oldest: u64, latest: u64, is_empty: bool) -> Vec anyhow::Result<()> { +fn print_bucket(ctx: &CliContext, bucket: FullBucketInfo, is_json: bool) -> anyhow::Result<()> { let info = bucket.info; let total_blocks = bucket .entries .iter() .map(|entry| entry.block_count) .sum::(); + + let record_data = record_range_cells_compact( + info.oldest_record, + info.latest_record, + info.entry_count == 0, + ); + + if is_json { + let bucket_json = serde_json::json!({ + "name": info.name, + "entries": info.entry_count, + "blocks": total_blocks, + "size": ByteSize(info.size).display().si().to_string(), + "status": print_bucket_status(&info.status), + "provisioned": if info.is_provisioned { "✓" } else { "-" }, + "Oldest Record (UTC)": record_data[0].split_once(": ").unwrap().1, + "Latest Record (UTC)": record_data[1].split_once(": ").unwrap().1, + }); + + output!( + ctx, + "{}", + serde_json::to_string_pretty(&bucket_json).unwrap() + ); + return Ok(()); + } + let mut info_cells = vec![ labeled_cell("Name", info.name), labeled_cell("Entries", info.entry_count), @@ -111,11 +140,8 @@ fn print_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result<()> labeled_cell("Status", print_bucket_status(&info.status)), labeled_cell("Provisioned", if info.is_provisioned { "✓" } else { "-" }), ]; - info_cells.extend(record_range_cells_compact( - info.oldest_record, - info.latest_record, - info.entry_count == 0, - )); + + info_cells.extend(record_data); let info_table = build_info_table_with_columns(info_cells, 1); @@ -124,7 +150,11 @@ fn print_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result<()> Ok(()) } -fn print_full_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result<()> { +fn print_full_bucket( + ctx: &CliContext, + bucket: FullBucketInfo, + is_json: bool, +) -> anyhow::Result<()> { let settings = bucket.settings; let info = bucket.info; let total_blocks = bucket @@ -132,6 +162,45 @@ fn print_full_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result .iter() .map(|entry| entry.block_count) .sum::(); + + let record_data = record_range_cells_compact( + info.oldest_record, + info.latest_record, + info.entry_count == 0, + ); + + let entries = bucket.entries.into_iter().map(EntryTable::from); + let entries: Vec = entries.collect::>(); + + if is_json { + let bucket_json = serde_json::json!({ + "name": info.name, + "quota_type": settings.quota_type.as_ref(), + "entry_count": info.entry_count, + "quota_size": ByteSize(settings.quota_size.unwrap()).display().si().to_string(), + "size": ByteSize(info.size).display().si().to_string(), + "max_block_size": ByteSize(settings.max_block_size.unwrap()).display().si().to_string(), + "blocks": total_blocks, + "max_block_records": settings.max_block_records.unwrap(), + "status": print_bucket_status(&info.status), + "provisioned": if info.is_provisioned { "✓" } else { "-" }, + }); + + let mut entries_json = serde_json::json!([]); + let entries_json = entries_json.as_array_mut().unwrap(); + for entry in entries { + entries_json.push(serde_json::json!(entry)); + } + + let json = serde_json::json!({ + "bucket": bucket_json, + "entries": entries_json, + }); + + output!(ctx, "{}", serde_json::to_string_pretty(&json).unwrap()); + return Ok(()); + } + let mut info_cells = vec![ labeled_cell("Name", info.name), labeled_cell("Quota Type", settings.quota_type.unwrap()), @@ -158,11 +227,7 @@ fn print_full_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result labeled_cell("Provisioned", if info.is_provisioned { "✓" } else { "-" }), String::new(), ]; - for cell in record_range_cells_compact( - info.oldest_record, - info.latest_record, - info.entry_count == 0, - ) { + for cell in record_data { info_cells.push(cell); info_cells.push(String::new()); } @@ -172,7 +237,6 @@ fn print_full_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result output!(ctx, "{}", info_table); output!(ctx, ""); - let entries = bucket.entries.into_iter().map(EntryTable::from); let table = Table::new(entries).with(Style::markdown()).to_string(); output!(ctx, "{}", table); @@ -182,7 +246,10 @@ fn print_full_bucket(ctx: &CliContext, bucket: FullBucketInfo) -> anyhow::Result #[cfg(test)] mod tests { use super::*; - use crate::context::tests::{bucket, context}; + use crate::context::{ + tests::{bucket, context, MockOutput}, + ContextBuilder, + }; use reduct_rs::ResourceStatus; use rstest::rstest; @@ -317,4 +384,95 @@ mod tests { assert_eq!(row.oldest_record, "---"); assert_eq!(row.latest_record, "---"); } + + #[rstest] + #[tokio::test] + async fn test_show_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 = show_bucket_cmd() + .get_matches_from(vec!["show", format!("local/{}", bucket_name).as_str()]); + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + show_bucket(&ctx, &args).await.unwrap(); + + let json: serde_json::Value = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); + + assert_eq!(json["name"], bucket_name); + assert_eq!(json["entries"], 0); + assert_eq!(json["blocks"], 0); + assert_eq!(json["size"], "0 B"); + assert_eq!(json["status"], "✅ Ready"); + assert_eq!(json["provisioned"], "-"); + assert_eq!(json["Oldest Record (UTC)"], "---"); + assert_eq!(json["Latest Record (UTC)"], "---"); + } + + #[rstest] + #[tokio::test] + async fn test_show_bucket_full_json(context: CliContext, #[future] bucket: String) { + let bucket_name = bucket.await; + let client = build_client(&context, "local").await.unwrap(); + let bucket = client.create_bucket(&bucket_name).send().await.unwrap(); + bucket + .write_record("test") + .data("data") + .timestamp_us(1) + .send() + .await + .unwrap(); + bucket + .write_record("test") + .data("data") + .timestamp_us(1000) + .send() + .await + .unwrap(); + + let args = show_bucket_cmd().get_matches_from(vec![ + "show", + format!("local/{}", bucket_name).as_str(), + "--full", + ]); + + let ctx = ContextBuilder::new() + .config_path(context.config_path()) + .json(Some(true)) + .output(Box::new(MockOutput::new())) + .build(); + + show_bucket(&ctx, &args).await.unwrap(); + + let json: serde_json::Value = serde_json::from_str(&ctx.stdout().history()[0]).unwrap(); + + // Verify bucket + let json_bucket = &json["bucket"]; + assert_eq!(json_bucket["name"], bucket_name); + assert_eq!(json_bucket["quota_type"], "NONE"); + assert_eq!(json_bucket["entry_count"], 1); + assert_eq!(json_bucket["quota_size"], "0 B"); + assert_eq!(json_bucket["size"], "77 B"); + assert_eq!(json_bucket["max_block_size"], "64.0 MB"); + assert_eq!(json_bucket["blocks"], 1); + assert_eq!(json_bucket["max_block_records"], 1024); + assert_eq!(json_bucket["status"], "✅ Ready"); + assert_eq!(json_bucket["provisioned"], "-"); + + // Verify entries + let json_entries = &json["entries"]; + assert_eq!(json_entries[0]["name"], "test"); + assert_eq!(json_entries[0]["record_count"], 2); + assert_eq!(json_entries[0]["block_count"], 1); + assert_eq!(json_entries[0]["status"], "✅ Ready"); + assert_eq!(json_entries[0]["size"], "77 B"); + assert_eq!(json_entries[0]["oldest_record"], "1970-01-01T00:00:00Z"); + assert_eq!(json_entries[0]["latest_record"], "1970-01-01T00:00:00Z"); + } } From 3a5ba087fbd462fb1f612ea85cdb4ab696d3b666 Mon Sep 17 00:00:00 2001 From: Malhar Vora Date: Sun, 2 Aug 2026 12:15:36 +0530 Subject: [PATCH 13/13] Update changelog Signed-off-by: Malhar Vora --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89aa461..900945f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add compression option to replica create, update, and show commands, [PR-261](https://github.com/reductstore/reduct-cli/pull/261) by @vbmade2000 - Improve time parsing for --start and --stop options, [PR-226](https://github.com/reductstore/reduct-cli/pull/226) by @yaslam-dev - Added `write` command to write a single record to a bucket, [PR-275](https://github.com/reductstore/reduct-cli/pull/275) by @vbmade2000 +- Add `--json` flag to `bucket ls`, `bucket show`, `bucket create`, `bucket update`, `bucket rename` and `bucket rm` command, [PR-274](https://github.com/reductstore/reduct-cli/pull/274) by @vbmade2000 ### Changed