Skip to main content

rustfs_cli/commands/
mod.rs

1//! CLI command definitions and execution
2//!
3//! This module contains all CLI commands and their implementations.
4//! Commands are organized by functionality and follow the pattern established
5//! in the command implementation template.
6
7use std::io::{IsTerminal, stderr, stdout};
8
9use clap::{Parser, Subcommand, ValueEnum};
10
11use crate::exit_code::ExitCode;
12use crate::output::OutputConfig;
13
14mod admin;
15mod alias;
16mod anonymous;
17mod bucket;
18mod cat;
19mod completions;
20mod cors;
21pub mod cp;
22pub mod diff;
23mod event;
24mod find;
25mod head;
26mod ilm;
27mod ls;
28mod mb;
29mod mirror;
30mod mv;
31mod object;
32mod pipe;
33mod quota;
34mod rb;
35mod replicate;
36mod rm;
37mod share;
38mod stat;
39mod tag;
40mod tree;
41mod version;
42
43/// rc - Rust S3 CLI Client
44///
45/// A command-line interface for S3-compatible object storage services.
46/// Supports RustFS, AWS S3, and other S3-compatible backends.
47#[derive(Parser, Debug)]
48#[command(name = "rc")]
49#[command(author, version, about, long_about = None)]
50#[command(propagate_version = true)]
51pub struct Cli {
52    /// Output format: auto-detect, human-readable, or JSON
53    #[arg(long, global = true, value_enum)]
54    pub format: Option<OutputFormat>,
55
56    /// Output format: human-readable or JSON
57    #[arg(long, global = true, default_value = "false")]
58    pub json: bool,
59
60    /// Disable colored output
61    #[arg(long, global = true, default_value = "false")]
62    pub no_color: bool,
63
64    /// Disable progress bar
65    #[arg(long, global = true, default_value = "false")]
66    pub no_progress: bool,
67
68    /// Suppress non-error output
69    #[arg(short, long, global = true, default_value = "false")]
70    pub quiet: bool,
71
72    /// Enable debug logging
73    #[arg(long, global = true, default_value = "false")]
74    pub debug: bool,
75
76    #[command(subcommand)]
77    pub command: Commands,
78}
79
80#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
81pub enum OutputFormat {
82    Auto,
83    Human,
84    Json,
85}
86
87#[derive(Copy, Clone, Debug, Eq, PartialEq)]
88enum OutputBehavior {
89    HumanDefault,
90    StructuredDefault,
91}
92
93#[derive(Copy, Clone, Debug)]
94struct GlobalOutputOptions {
95    format: Option<OutputFormat>,
96    json: bool,
97    no_color: bool,
98    no_progress: bool,
99    quiet: bool,
100}
101
102impl GlobalOutputOptions {
103    fn from_cli(cli: &Cli) -> Self {
104        Self {
105            format: cli.format,
106            json: cli.json,
107            no_color: cli.no_color,
108            no_progress: cli.no_progress,
109            quiet: cli.quiet,
110        }
111    }
112
113    fn resolve(self, behavior: OutputBehavior) -> OutputConfig {
114        let stdout_is_tty = stdout().is_terminal();
115        let stderr_is_tty = stderr().is_terminal();
116
117        let selected_format = if self.json {
118            OutputFormat::Json
119        } else {
120            self.format.unwrap_or(match behavior {
121                OutputBehavior::HumanDefault => OutputFormat::Human,
122                OutputBehavior::StructuredDefault => OutputFormat::Auto,
123            })
124        };
125
126        let json = match selected_format {
127            OutputFormat::Json => true,
128            OutputFormat::Human => false,
129            OutputFormat::Auto => !stdout_is_tty,
130        };
131
132        OutputConfig {
133            json,
134            no_color: self.no_color || !stdout_is_tty || json,
135            no_progress: self.no_progress || !stderr_is_tty || json,
136            quiet: self.quiet,
137        }
138    }
139}
140
141#[derive(Subcommand, Debug)]
142pub enum Commands {
143    /// Manage storage service aliases
144    #[command(subcommand)]
145    Alias(alias::AliasCommands),
146
147    /// Manage IAM users, policies, groups, and service accounts
148    #[command(subcommand)]
149    Admin(admin::AdminCommands),
150
151    /// Manage bucket-oriented workflows
152    Bucket(bucket::BucketArgs),
153
154    /// Manage object-oriented workflows
155    Object(object::ObjectArgs),
156
157    // Phase 2: Basic commands
158    /// Deprecated: use `rc bucket list` or `rc object list`
159    Ls(ls::LsArgs),
160
161    /// Deprecated: use `rc bucket create`
162    Mb(mb::MbArgs),
163
164    /// Deprecated: use `rc bucket remove`
165    Rb(rb::RbArgs),
166
167    /// Deprecated: use `rc object show`
168    Cat(cat::CatArgs),
169
170    /// Deprecated: use `rc object head`
171    Head(head::HeadArgs),
172
173    /// Deprecated: use `rc object stat`
174    Stat(stat::StatArgs),
175
176    // Phase 3: Transfer commands
177    /// Deprecated: use `rc object copy`
178    Cp(cp::CpArgs),
179
180    /// Deprecated: use `rc object move`
181    Mv(mv::MvArgs),
182
183    /// Deprecated: use `rc object remove`
184    Rm(rm::RmArgs),
185
186    /// Stream stdin to an object
187    Pipe(pipe::PipeArgs),
188
189    // Phase 4: Advanced commands
190    /// Deprecated: use `rc object find`
191    Find(find::FindArgs),
192
193    /// Deprecated: use `rc bucket event`
194    Event(event::EventArgs),
195
196    /// Deprecated: use `rc bucket cors`
197    #[command(subcommand)]
198    Cors(cors::CorsCommands),
199
200    /// Show differences between locations
201    Diff(diff::DiffArgs),
202
203    /// Mirror objects between locations
204    Mirror(mirror::MirrorArgs),
205
206    /// Deprecated: use `rc object tree`
207    Tree(tree::TreeArgs),
208
209    /// Deprecated: use `rc object share`
210    Share(share::ShareArgs),
211
212    // Phase 5: Optional commands (capability-dependent)
213    /// Deprecated: use `rc bucket version`
214    #[command(subcommand)]
215    Version(version::VersionCommands),
216
217    /// Manage bucket and object tags
218    #[command(subcommand)]
219    Tag(tag::TagCommands),
220
221    /// Deprecated: use `rc bucket anonymous`
222    #[command(subcommand)]
223    Anonymous(anonymous::AnonymousCommands),
224
225    /// Deprecated: use `rc bucket quota`
226    #[command(subcommand)]
227    Quota(quota::QuotaCommands),
228
229    /// Deprecated: use `rc bucket lifecycle`
230    Ilm(ilm::IlmArgs),
231
232    /// Deprecated: use `rc bucket replication`
233    Replicate(replicate::ReplicateArgs),
234
235    // Phase 6: Utilities
236    /// Generate shell completion scripts
237    Completions(completions::CompletionsArgs),
238    // /// Manage object retention
239    // Retention(retention::RetentionArgs),
240    // /// Watch for object events
241    // Watch(watch::WatchArgs),
242    // /// Run S3 Select queries
243    // Sql(sql::SqlArgs),
244}
245
246/// Execute the CLI command and return an exit code
247pub async fn execute(cli: Cli) -> ExitCode {
248    let output_options = GlobalOutputOptions::from_cli(&cli);
249
250    match cli.command {
251        Commands::Alias(cmd) => {
252            alias::execute(cmd, output_options.resolve(OutputBehavior::HumanDefault)).await
253        }
254        Commands::Admin(cmd) => {
255            admin::execute(cmd, output_options.resolve(OutputBehavior::HumanDefault)).await
256        }
257        Commands::Bucket(args) => {
258            bucket::execute(
259                args,
260                output_options.resolve(OutputBehavior::StructuredDefault),
261            )
262            .await
263        }
264        Commands::Object(args) => {
265            let behavior = match &args.command {
266                object::ObjectCommands::Show(_) | object::ObjectCommands::Head(_) => {
267                    OutputBehavior::HumanDefault
268                }
269                _ => OutputBehavior::StructuredDefault,
270            };
271            object::execute(args, output_options.resolve(behavior)).await
272        }
273        Commands::Ls(args) => {
274            ls::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
275        }
276        Commands::Mb(args) => {
277            mb::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
278        }
279        Commands::Rb(args) => {
280            rb::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
281        }
282        Commands::Cat(args) => {
283            cat::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
284        }
285        Commands::Head(args) => {
286            head::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
287        }
288        Commands::Stat(args) => {
289            stat::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
290        }
291        Commands::Cp(args) => {
292            cp::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
293        }
294        Commands::Mv(args) => {
295            mv::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
296        }
297        Commands::Rm(args) => {
298            rm::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
299        }
300        Commands::Pipe(args) => {
301            pipe::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
302        }
303        Commands::Find(args) => {
304            find::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
305        }
306        Commands::Event(args) => {
307            event::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
308        }
309        Commands::Cors(cmd) => {
310            cors::execute(
311                cors::CorsArgs { command: cmd },
312                output_options.resolve(OutputBehavior::HumanDefault),
313            )
314            .await
315        }
316        Commands::Diff(args) => {
317            diff::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
318        }
319        Commands::Mirror(args) => {
320            mirror::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
321        }
322        Commands::Tree(args) => {
323            tree::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
324        }
325        Commands::Share(args) => {
326            share::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
327        }
328        Commands::Version(cmd) => {
329            version::execute(
330                version::VersionArgs { command: cmd },
331                output_options.resolve(OutputBehavior::HumanDefault),
332            )
333            .await
334        }
335        Commands::Tag(cmd) => {
336            tag::execute(
337                tag::TagArgs { command: cmd },
338                output_options.resolve(OutputBehavior::HumanDefault),
339            )
340            .await
341        }
342        Commands::Anonymous(cmd) => {
343            anonymous::execute(
344                anonymous::AnonymousArgs { command: cmd },
345                output_options.resolve(OutputBehavior::HumanDefault),
346            )
347            .await
348        }
349        Commands::Quota(cmd) => {
350            quota::execute(
351                quota::QuotaArgs { command: cmd },
352                output_options.resolve(OutputBehavior::HumanDefault),
353            )
354            .await
355        }
356        Commands::Ilm(args) => {
357            ilm::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
358        }
359        Commands::Replicate(args) => {
360            replicate::execute(args, output_options.resolve(OutputBehavior::HumanDefault)).await
361        }
362        Commands::Completions(args) => completions::execute(args),
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use clap::Parser;
370
371    #[test]
372    fn structured_default_uses_auto_format_when_not_explicit() {
373        let options = GlobalOutputOptions {
374            format: None,
375            json: false,
376            no_color: false,
377            no_progress: false,
378            quiet: false,
379        };
380
381        let resolved = options.resolve(OutputBehavior::StructuredDefault);
382        assert_eq!(resolved.json, !std::io::stdout().is_terminal());
383    }
384
385    #[test]
386    fn human_default_keeps_human_format_when_not_explicit() {
387        let options = GlobalOutputOptions {
388            format: None,
389            json: false,
390            no_color: false,
391            no_progress: false,
392            quiet: false,
393        };
394
395        let resolved = options.resolve(OutputBehavior::HumanDefault);
396        assert!(!resolved.json);
397    }
398
399    #[test]
400    fn explicit_json_overrides_behavior_defaults() {
401        let options = GlobalOutputOptions {
402            format: Some(OutputFormat::Human),
403            json: true,
404            no_color: false,
405            no_progress: false,
406            quiet: false,
407        };
408
409        let resolved = options.resolve(OutputBehavior::HumanDefault);
410        assert!(resolved.json);
411    }
412
413    #[test]
414    fn cli_accepts_bucket_cors_subcommand() {
415        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket"])
416            .expect("parse bucket cors");
417
418        match cli.command {
419            Commands::Bucket(args) => match args.command {
420                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
421                    assert_eq!(arg.path, "local/my-bucket");
422                }
423                other => panic!("expected bucket cors list command, got {:?}", other),
424            },
425            other => panic!("expected bucket command, got {:?}", other),
426        }
427    }
428
429    #[test]
430    fn cli_accepts_top_level_cors_subcommand() {
431        let cli = Cli::try_parse_from(["rc", "cors", "remove", "local/my-bucket"])
432            .expect("parse top-level cors");
433
434        match cli.command {
435            Commands::Cors(cors::CorsCommands::Remove(arg)) => {
436                assert_eq!(arg.path, "local/my-bucket");
437            }
438            other => panic!("expected top-level cors remove command, got {:?}", other),
439        }
440    }
441
442    #[test]
443    fn cli_accepts_bucket_cors_get_alias() {
444        let cli = Cli::try_parse_from(["rc", "bucket", "cors", "get", "local/my-bucket"])
445            .expect("parse bucket cors get");
446
447        match cli.command {
448            Commands::Bucket(args) => match args.command {
449                bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
450                    assert_eq!(arg.path, "local/my-bucket");
451                }
452                other => panic!("expected bucket cors get alias, got {:?}", other),
453            },
454            other => panic!("expected bucket command, got {:?}", other),
455        }
456    }
457
458    #[test]
459    fn cli_accepts_bucket_cors_set_with_positional_source() {
460        let cli =
461            Cli::try_parse_from(["rc", "bucket", "cors", "set", "local/my-bucket", "cors.xml"])
462                .expect("parse bucket cors set with positional source");
463
464        match cli.command {
465            Commands::Bucket(args) => match args.command {
466                bucket::BucketCommands::Cors(cors::CorsCommands::Set(arg)) => {
467                    assert_eq!(arg.path, "local/my-bucket");
468                    assert_eq!(arg.source.as_deref(), Some("cors.xml"));
469                }
470                other => panic!("expected bucket cors set command, got {:?}", other),
471            },
472            other => panic!("expected bucket command, got {:?}", other),
473        }
474    }
475}