Skip to content

Commit be3d92f

Browse files
committed
feat(cli): add colored output styling for better readability
- Add Theme struct with exa/eza-inspired color scheme - Extend Formatter with style helper methods: - style_dir() - blue + bold for directories - style_file() - default for files - style_size() - green for file sizes - style_date() - dim for timestamps - style_key() - cyan for property keys - style_url() - cyan + underline for URLs - style_name() - bold for alias/bucket names - style_tree_branch() - dim for tree characters - Apply styling to ls, alias, stat, tree, find, rm, cp, share commands - Use 10-char width for size column to accommodate values like '941.75 KiB' - Respect --no-color flag and JSON mode
1 parent 78d67e8 commit be3d92f

12 files changed

Lines changed: 316 additions & 146 deletions

File tree

crates/cli/src/commands/alias.rs

Lines changed: 43 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use clap::Subcommand;
77
use serde::Serialize;
88

99
use crate::exit_code::ExitCode;
10+
use crate::output::{Formatter, OutputConfig};
1011
use rc_core::{Alias, AliasManager};
1112

1213
/// Alias subcommands for managing storage service connections
@@ -104,67 +105,44 @@ struct AliasOperationOutput {
104105
}
105106

106107
/// Execute an alias subcommand
107-
pub async fn execute(cmd: AliasCommands, json_output: bool) -> ExitCode {
108+
pub async fn execute(cmd: AliasCommands, output_config: OutputConfig) -> ExitCode {
109+
let formatter = Formatter::new(output_config);
108110
let alias_manager = match AliasManager::new() {
109111
Ok(am) => am,
110112
Err(e) => {
111-
if json_output {
112-
eprintln!("{}", serde_json::json!({"error": e.to_string()}));
113-
} else {
114-
eprintln!("Error: {e}");
115-
}
113+
formatter.error(&format!("Failed to load aliases: {e}"));
116114
return ExitCode::GeneralError;
117115
}
118116
};
119117

120118
match cmd {
121-
AliasCommands::Set(args) => execute_set(args, &alias_manager, json_output).await,
122-
AliasCommands::List(args) => execute_list(args, &alias_manager, json_output).await,
123-
AliasCommands::Remove(args) => execute_remove(args, &alias_manager, json_output).await,
119+
AliasCommands::Set(args) => execute_set(args, &alias_manager, &formatter).await,
120+
AliasCommands::List(args) => execute_list(args, &alias_manager, &formatter).await,
121+
AliasCommands::Remove(args) => execute_remove(args, &alias_manager, &formatter).await,
124122
}
125123
}
126124

127-
async fn execute_set(args: SetArgs, manager: &AliasManager, json_output: bool) -> ExitCode {
125+
async fn execute_set(args: SetArgs, manager: &AliasManager, formatter: &Formatter) -> ExitCode {
128126
// Validate inputs
129127
if args.name.is_empty() {
130-
let msg = "Alias name cannot be empty";
131-
if json_output {
132-
eprintln!("{}", serde_json::json!({"error": msg}));
133-
} else {
134-
eprintln!("Error: {msg}");
135-
}
128+
formatter.error("Alias name cannot be empty");
136129
return ExitCode::UsageError;
137130
}
138131

139132
if args.endpoint.is_empty() {
140-
let msg = "Endpoint URL cannot be empty";
141-
if json_output {
142-
eprintln!("{}", serde_json::json!({"error": msg}));
143-
} else {
144-
eprintln!("Error: {msg}");
145-
}
133+
formatter.error("Endpoint URL cannot be empty");
146134
return ExitCode::UsageError;
147135
}
148136

149137
// Validate signature version
150138
if args.signature != "v4" && args.signature != "v2" {
151-
let msg = "Signature must be 'v4' or 'v2'";
152-
if json_output {
153-
eprintln!("{}", serde_json::json!({"error": msg}));
154-
} else {
155-
eprintln!("Error: {msg}");
156-
}
139+
formatter.error("Signature must be 'v4' or 'v2'");
157140
return ExitCode::UsageError;
158141
}
159142

160143
// Validate bucket lookup
161144
if args.bucket_lookup != "auto" && args.bucket_lookup != "path" && args.bucket_lookup != "dns" {
162-
let msg = "Bucket lookup must be 'auto', 'path', or 'dns'";
163-
if json_output {
164-
eprintln!("{}", serde_json::json!({"error": msg}));
165-
} else {
166-
eprintln!("Error: {msg}");
167-
}
145+
formatter.error("Bucket lookup must be 'auto', 'path', or 'dns'");
168146
return ExitCode::UsageError;
169147
}
170148

@@ -183,98 +161,90 @@ async fn execute_set(args: SetArgs, manager: &AliasManager, json_output: bool) -
183161
// Save alias
184162
match manager.set(alias) {
185163
Ok(()) => {
186-
if json_output {
164+
if formatter.is_json() {
187165
let output = AliasOperationOutput {
188166
success: true,
189167
alias: args.name.clone(),
190168
message: format!("Alias '{}' configured successfully", args.name),
191169
};
192-
println!("{}", serde_json::to_string_pretty(&output).unwrap());
170+
formatter.json(&output);
193171
} else {
194-
println!("Alias '{}' configured successfully.", args.name);
172+
let styled_name = formatter.style_name(&args.name);
173+
formatter.success(&format!("Alias '{styled_name}' configured successfully."));
195174
}
196175
ExitCode::Success
197176
}
198177
Err(e) => {
199-
if json_output {
200-
eprintln!("{}", serde_json::json!({"error": e.to_string()}));
201-
} else {
202-
eprintln!("Error: {e}");
203-
}
178+
formatter.error(&e.to_string());
204179
ExitCode::GeneralError
205180
}
206181
}
207182
}
208183

209-
async fn execute_list(args: ListArgs, manager: &AliasManager, json_output: bool) -> ExitCode {
184+
async fn execute_list(args: ListArgs, manager: &AliasManager, formatter: &Formatter) -> ExitCode {
210185
match manager.list() {
211186
Ok(aliases) => {
212-
if json_output {
187+
if formatter.is_json() {
213188
let output = AliasListOutput {
214189
aliases: aliases.iter().map(AliasInfo::from).collect(),
215190
};
216-
println!("{}", serde_json::to_string_pretty(&output).unwrap());
191+
formatter.json(&output);
217192
} else if aliases.is_empty() {
218-
println!("No aliases configured.");
193+
formatter.println("No aliases configured.");
219194
} else if args.long {
220195
// Long format with details
221196
for alias in &aliases {
222-
println!(
223-
"{:<12} {} (region: {}, lookup: {})",
224-
alias.name, alias.endpoint, alias.region, alias.bucket_lookup
225-
);
197+
let styled_name = formatter.style_name(&format!("{:<12}", alias.name));
198+
let styled_url = formatter.style_url(&alias.endpoint);
199+
let styled_region = formatter.style_date(&alias.region);
200+
let styled_lookup = formatter.style_date(&alias.bucket_lookup);
201+
formatter.println(&format!(
202+
"{styled_name} {styled_url} (region: {styled_region}, lookup: {styled_lookup})"
203+
));
226204
}
227205
} else {
228206
// Short format
229207
for alias in &aliases {
230-
println!("{:<12} {}", alias.name, alias.endpoint);
208+
let styled_name = formatter.style_name(&format!("{:<12}", alias.name));
209+
let styled_url = formatter.style_url(&alias.endpoint);
210+
formatter.println(&format!("{styled_name} {styled_url}"));
231211
}
232212
}
233213
ExitCode::Success
234214
}
235215
Err(e) => {
236-
if json_output {
237-
eprintln!("{}", serde_json::json!({"error": e.to_string()}));
238-
} else {
239-
eprintln!("Error: {e}");
240-
}
216+
formatter.error(&e.to_string());
241217
ExitCode::GeneralError
242218
}
243219
}
244220
}
245221

246-
async fn execute_remove(args: RemoveArgs, manager: &AliasManager, json_output: bool) -> ExitCode {
222+
async fn execute_remove(
223+
args: RemoveArgs,
224+
manager: &AliasManager,
225+
formatter: &Formatter,
226+
) -> ExitCode {
247227
match manager.remove(&args.name) {
248228
Ok(()) => {
249-
if json_output {
229+
if formatter.is_json() {
250230
let output = AliasOperationOutput {
251231
success: true,
252232
alias: args.name.clone(),
253233
message: format!("Alias '{}' removed successfully", args.name),
254234
};
255-
println!("{}", serde_json::to_string_pretty(&output).unwrap());
235+
formatter.json(&output);
256236
} else {
257-
println!("Alias '{}' removed successfully.", args.name);
237+
let styled_name = formatter.style_name(&args.name);
238+
formatter.success(&format!("Alias '{styled_name}' removed successfully."));
258239
}
259240
ExitCode::Success
260241
}
261242
Err(rc_core::Error::AliasNotFound(_)) => {
262-
if json_output {
263-
eprintln!(
264-
"{}",
265-
serde_json::json!({"error": format!("Alias '{}' not found", args.name)})
266-
);
267-
} else {
268-
eprintln!("Error: Alias '{}' not found.", args.name);
269-
}
243+
formatter.error(&format!("Alias '{}' not found", args.name));
270244
ExitCode::NotFound
271245
}
272246
Err(e) => {
273-
if json_output {
274-
eprintln!("{}", serde_json::json!({"error": e.to_string()}));
275-
} else {
276-
eprintln!("Error: {e}");
277-
}
247+
formatter.error(&e.to_string());
278248
ExitCode::GeneralError
279249
}
280250
}

crates/cli/src/commands/cp.rs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ async fn upload_file(
175175
let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst_key);
176176

177177
if args.dry_run {
178-
formatter.println(&format!("Would copy: {src_display} -> {dst_display}"));
178+
let styled_src = formatter.style_file(&src_display);
179+
let styled_dst = formatter.style_file(&dst_display);
180+
formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
179181
return ExitCode::Success;
180182
}
181183

@@ -209,10 +211,10 @@ async fn upload_file(
209211
};
210212
formatter.json(&output);
211213
} else {
212-
formatter.println(&format!(
213-
"{src_display} -> {dst_display} ({})",
214-
info.size_human.unwrap_or_default()
215-
));
214+
let styled_src = formatter.style_file(&src_display);
215+
let styled_dst = formatter.style_file(&dst_display);
216+
let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
217+
formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
216218
}
217219
ExitCode::Success
218220
}
@@ -360,7 +362,9 @@ async fn download_file(
360362
let dst_display = dst_path.display().to_string();
361363

362364
if args.dry_run {
363-
formatter.println(&format!("Would copy: {src_display} -> {dst_display}"));
365+
let styled_src = formatter.style_file(&src_display);
366+
let styled_dst = formatter.style_file(&dst_display);
367+
formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
364368
return ExitCode::Success;
365369
}
366370

@@ -401,10 +405,11 @@ async fn download_file(
401405
};
402406
formatter.json(&output);
403407
} else {
404-
formatter.println(&format!(
405-
"{src_display} -> {dst_display} ({})",
406-
humansize::format_size(size as u64, humansize::BINARY)
407-
));
408+
let styled_src = formatter.style_file(&src_display);
409+
let styled_dst = formatter.style_file(&dst_display);
410+
let styled_size =
411+
formatter.style_size(&humansize::format_size(size as u64, humansize::BINARY));
412+
formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
408413
}
409414
ExitCode::Success
410415
}
@@ -537,7 +542,9 @@ async fn copy_s3_to_s3(
537542
let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
538543

539544
if args.dry_run {
540-
formatter.println(&format!("Would copy: {src_display} -> {dst_display}"));
545+
let styled_src = formatter.style_file(&src_display);
546+
let styled_dst = formatter.style_file(&dst_display);
547+
formatter.println(&format!("Would copy: {styled_src} -> {styled_dst}"));
541548
return ExitCode::Success;
542549
}
543550

@@ -553,10 +560,10 @@ async fn copy_s3_to_s3(
553560
};
554561
formatter.json(&output);
555562
} else {
556-
formatter.println(&format!(
557-
"{src_display} -> {dst_display} ({})",
558-
info.size_human.unwrap_or_default()
559-
));
563+
let styled_src = formatter.style_file(&src_display);
564+
let styled_dst = formatter.style_file(&dst_display);
565+
let styled_size = formatter.style_size(&info.size_human.unwrap_or_default());
566+
formatter.println(&format!("{styled_src} -> {styled_dst} ({styled_size})"));
560567
}
561568
ExitCode::Success
562569
}

crates/cli/src/commands/find.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,11 @@ pub async fn execute(args: FindArgs, output_config: OutputConfig) -> ExitCode {
144144
});
145145
formatter.json(&output);
146146
} else {
147+
let total_size_human = humansize::format_size(total_size as u64, humansize::BINARY);
147148
formatter.println(&format!(
148149
"Found {} object(s), total size: {}",
149-
total_count,
150-
humansize::format_size(total_size as u64, humansize::BINARY)
150+
formatter.style_size(&total_count.to_string()),
151+
formatter.style_size(&total_size_human)
151152
));
152153
}
153154
} else if formatter.is_json() {
@@ -162,13 +163,16 @@ pub async fn execute(args: FindArgs, output_config: OutputConfig) -> ExitCode {
162163
formatter.println("No matches found.");
163164
} else {
164165
for m in &matches {
165-
let size = m.size_human.as_deref().unwrap_or(" 0B");
166-
formatter.println(&format!("{:>8} {}", size, m.key));
166+
let size = m.size_human.as_deref().unwrap_or("0B");
167+
let styled_size = formatter.style_size(&format!("{:>10}", size));
168+
let styled_key = formatter.style_file(&m.key);
169+
formatter.println(&format!("{styled_size} {styled_key}"));
167170
}
171+
let total_size_human = humansize::format_size(total_size as u64, humansize::BINARY);
168172
formatter.println(&format!(
169173
"\nTotal: {} object(s), {}",
170-
total_count,
171-
humansize::format_size(total_size as u64, humansize::BINARY)
174+
formatter.style_size(&total_count.to_string()),
175+
formatter.style_size(&total_size_human)
172176
));
173177
}
174178

crates/cli/src/commands/ls.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,16 @@ async fn list_buckets(client: &S3Client, formatter: &Formatter, summarize: bool)
127127
.last_modified
128128
.map(|d| d.strftime("%Y-%m-%d %H:%M:%S").to_string())
129129
.unwrap_or_else(|| " ".to_string());
130-
formatter.println(&format!("[{date}] 0B {}/", bucket.key));
130+
let styled_date = formatter.style_date(&format!("[{date}]"));
131+
let styled_size = formatter.style_size(&format!("{:>10}", "0B"));
132+
let styled_name = formatter.style_dir(&format!("{}/", bucket.key));
133+
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
131134
}
132135
if summarize {
133-
formatter.println(&format!("\nTotal: {} buckets", buckets.len()));
136+
formatter.println(&format!(
137+
"\nTotal: {} buckets",
138+
formatter.style_size(&buckets.len().to_string())
139+
));
134140
}
135141
}
136142
ExitCode::Success
@@ -213,20 +219,26 @@ async fn list_objects(
213219
.last_modified
214220
.map(|d| d.strftime("%Y-%m-%d %H:%M:%S").to_string())
215221
.unwrap_or_else(|| " ".to_string());
222+
let styled_date = formatter.style_date(&format!("[{date}]"));
216223

217224
if item.is_dir {
218-
formatter.println(&format!("[{date}] 0B {}", item.key));
225+
let styled_size = formatter.style_size(&format!("{:>10}", "0B"));
226+
let styled_name = formatter.style_dir(&item.key);
227+
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
219228
} else {
220229
let size = item.size_human.clone().unwrap_or_else(|| "0 B".to_string());
221-
formatter.println(&format!("[{date}] {:>6} {}", size, item.key));
230+
let styled_size = formatter.style_size(&format!("{:>10}", size));
231+
let styled_name = formatter.style_file(&item.key);
232+
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
222233
}
223234
}
224235

225236
if args.summarize {
237+
let total_size_human = humansize::format_size(total_size as u64, humansize::BINARY);
226238
formatter.println(&format!(
227239
"\nTotal: {} objects, {}",
228-
total_objects,
229-
humansize::format_size(total_size as u64, humansize::BINARY)
240+
formatter.style_size(&total_objects.to_string()),
241+
formatter.style_size(&total_size_human)
230242
));
231243
}
232244
}

crates/cli/src/commands/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ pub async fn execute(cli: Cli) -> ExitCode {
146146
};
147147

148148
match cli.command {
149-
Commands::Alias(cmd) => alias::execute(cmd, cli.json).await,
149+
Commands::Alias(cmd) => alias::execute(cmd, output_config).await,
150150
Commands::Ls(args) => ls::execute(args, output_config).await,
151151
Commands::Mb(args) => mb::execute(args, output_config).await,
152152
Commands::Rb(args) => rb::execute(args, output_config).await,

0 commit comments

Comments
 (0)