From ea6ead2878a620f950921a898f96edc53efa7bf0 Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 9 Mar 2026 04:15:16 +0800 Subject: [PATCH] feat(phase-2): add CLI help contract coverage and gate --- .github/workflows/integration.yml | 12 + crates/cli/tests/help_contract.rs | 499 ++++++++++++++++++++++++++++++ 2 files changed, 511 insertions(+) create mode 100644 crates/cli/tests/help_contract.rs diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index e2a0b8e..d7fe819 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -17,6 +17,18 @@ env: TEST_S3_SECRET_KEY: secretkey jobs: + cli-contract: + name: CLI Contract (Commands and Options) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Run CLI help contract tests + run: cargo test --package rustfs-cli --test help_contract + smoke-latest: name: Smoke (RustFS latest) runs-on: ubuntu-latest diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs new file mode 100644 index 0000000..61ee0dd --- /dev/null +++ b/crates/cli/tests/help_contract.rs @@ -0,0 +1,499 @@ +//! CLI help contract tests. +//! +//! These tests verify command and option discoverability through `--help` output. +//! They provide a fast contract layer for all command paths, without requiring +//! a running S3 backend. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +const GLOBAL_OPTIONS: &[&str] = &[ + "--json", + "--no-color", + "--no-progress", + "--quiet", + "--debug", + "--help", + "--version", +]; + +#[derive(Debug)] +struct HelpCase { + args: &'static [&'static str], + usage: &'static str, + expected_tokens: &'static [&'static str], +} + +fn rc_binary() -> PathBuf { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_rc") { + return PathBuf::from(path); + } + + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("cli crate has parent directory") + .parent() + .expect("workspace root exists") + .to_path_buf(); + + let debug_binary = workspace_root.join("target/debug/rc"); + if debug_binary.exists() { + return debug_binary; + } + + workspace_root.join("target/release/rc") +} + +fn run_rc(args: &[&str]) -> Output { + Command::new(rc_binary()) + .args(args) + .output() + .expect("failed to execute rc") +} + +fn assert_help_case(case: &HelpCase) { + let mut args = case.args.to_vec(); + args.push("--help"); + + let output = run_rc(&args); + let command_label = if case.args.is_empty() { + "rc".to_string() + } else { + format!("rc {}", case.args.join(" ")) + }; + + assert!( + output.status.success(), + "help should succeed for {command_label}: stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(case.usage), + "usage marker `{}` missing for {command_label}\nstdout:\n{}", + case.usage, + stdout + ); + + for option in GLOBAL_OPTIONS { + assert!( + stdout.contains(option), + "global option `{option}` missing in help for {command_label}\nstdout:\n{}", + stdout + ); + } + + for token in case.expected_tokens { + assert!( + stdout.contains(token), + "expected token `{token}` missing in help for {command_label}\nstdout:\n{}", + stdout + ); + } +} + +#[test] +fn top_level_command_help_contract() { + let cases = [ + HelpCase { + args: &[], + usage: "Usage: rc [OPTIONS] ", + expected_tokens: &[ + "alias", + "admin", + "ls", + "mb", + "rb", + "cat", + "head", + "stat", + "cp", + "mv", + "rm", + "pipe", + "find", + "diff", + "mirror", + "tree", + "share", + "version", + "tag", + "quota", + "completions", + ], + }, + HelpCase { + args: &["alias"], + usage: "Usage: rc alias [OPTIONS] ", + expected_tokens: &["set", "list", "remove"], + }, + HelpCase { + args: &["admin"], + usage: "Usage: rc admin [OPTIONS] ", + expected_tokens: &["info", "heal", "user", "policy", "group", "service-account"], + }, + HelpCase { + args: &["ls"], + usage: "Usage: rc ls [OPTIONS] ", + expected_tokens: &["--recursive", "--versions", "--incomplete", "--summarize"], + }, + HelpCase { + args: &["mb"], + usage: "Usage: rc mb [OPTIONS] ", + expected_tokens: &[ + "--ignore-existing", + "--region", + "--with-lock", + "--with-versioning", + ], + }, + HelpCase { + args: &["rb"], + usage: "Usage: rc rb [OPTIONS] ", + expected_tokens: &["--force", "--dangerous"], + }, + HelpCase { + args: &["cat"], + usage: "Usage: rc cat [OPTIONS] ", + expected_tokens: &["--enc-key", "--rewind", "--version-id"], + }, + HelpCase { + args: &["head"], + usage: "Usage: rc head [OPTIONS] ", + expected_tokens: &["--lines", "--bytes", "--version-id"], + }, + HelpCase { + args: &["stat"], + usage: "Usage: rc stat [OPTIONS] ", + expected_tokens: &["--version-id", "--rewind"], + }, + HelpCase { + args: &["cp"], + usage: "Usage: rc cp [OPTIONS] ", + expected_tokens: &[ + "--recursive", + "--preserve", + "--continue-on-error", + "--overwrite", + "--dry-run", + "--storage-class", + "--content-type", + ], + }, + HelpCase { + args: &["mv"], + usage: "Usage: rc mv [OPTIONS] ", + expected_tokens: &["--recursive", "--continue-on-error", "--dry-run"], + }, + HelpCase { + args: &["rm"], + usage: "Usage: rc rm [OPTIONS] ...", + expected_tokens: &[ + "--recursive", + "--force", + "--dry-run", + "--incomplete", + "--versions", + "--bypass", + ], + }, + HelpCase { + args: &["pipe"], + usage: "Usage: rc pipe [OPTIONS] ", + expected_tokens: &["--content-type", "--storage-class"], + }, + HelpCase { + args: &["find"], + usage: "Usage: rc find [OPTIONS] ", + expected_tokens: &[ + "--name", + "--larger", + "--smaller", + "--newer", + "--older", + "--maxdepth", + "--count", + "--exec", + "--print", + ], + }, + HelpCase { + args: &["diff"], + usage: "Usage: rc diff [OPTIONS] ", + expected_tokens: &["--recursive", "--diff-only"], + }, + HelpCase { + args: &["mirror"], + usage: "Usage: rc mirror [OPTIONS] ", + expected_tokens: &["--remove", "--overwrite", "--dry-run", "--parallel"], + }, + HelpCase { + args: &["tree"], + usage: "Usage: rc tree [OPTIONS] ", + expected_tokens: &[ + "--level", + "--size", + "--dirs-only", + "--pattern", + "--full-path", + ], + }, + HelpCase { + args: &["share"], + usage: "Usage: rc share [OPTIONS] ", + expected_tokens: &["--expire", "--upload", "--content-type"], + }, + HelpCase { + args: &["version"], + usage: "Usage: rc version [OPTIONS] ", + expected_tokens: &["enable", "suspend", "info", "list"], + }, + HelpCase { + args: &["tag"], + usage: "Usage: rc tag [OPTIONS] ", + expected_tokens: &["list", "set", "remove"], + }, + HelpCase { + args: &["quota"], + usage: "Usage: rc quota [OPTIONS] ", + expected_tokens: &["set", "info", "clear"], + }, + HelpCase { + args: &["completions"], + usage: "Usage: rc completions [OPTIONS] ", + expected_tokens: &["[possible values: bash, elvish, fish, powershell, zsh]"], + }, + ]; + + for case in cases { + assert_help_case(&case); + } +} + +#[test] +fn nested_subcommand_help_contract() { + let cases = [ + HelpCase { + args: &["alias", "set"], + usage: "Usage: rc alias set [OPTIONS] ", + expected_tokens: &["--region", "--signature", "--bucket-lookup", "--insecure"], + }, + HelpCase { + args: &["alias", "list"], + usage: "Usage: rc alias list [OPTIONS]", + expected_tokens: &["--long"], + }, + HelpCase { + args: &["alias", "remove"], + usage: "Usage: rc alias remove [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "info", "cluster"], + usage: "Usage: rc admin info cluster [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "info", "server"], + usage: "Usage: rc admin info server [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "info", "disk"], + usage: "Usage: rc admin info disk [OPTIONS] ", + expected_tokens: &["--offline", "--healing"], + }, + HelpCase { + args: &["admin", "heal", "status"], + usage: "Usage: rc admin heal status [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "heal", "start"], + usage: "Usage: rc admin heal start [OPTIONS] ", + expected_tokens: &[ + "--bucket", + "--prefix", + "--scan-mode", + "--remove", + "--recreate", + "--dry-run", + ], + }, + HelpCase { + args: &["admin", "heal", "stop"], + usage: "Usage: rc admin heal stop [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "user", "ls"], + usage: "Usage: rc admin user ls [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "user", "add"], + usage: "Usage: rc admin user add [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "user", "info"], + usage: "Usage: rc admin user info [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "user", "rm"], + usage: "Usage: rc admin user rm [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "user", "enable"], + usage: "Usage: rc admin user enable [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "user", "disable"], + usage: "Usage: rc admin user disable [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "policy", "ls"], + usage: "Usage: rc admin policy ls [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "policy", "create"], + usage: "Usage: rc admin policy create [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "policy", "info"], + usage: "Usage: rc admin policy info [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "policy", "rm"], + usage: "Usage: rc admin policy rm [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "policy", "attach"], + usage: "Usage: rc admin policy attach [OPTIONS] ", + expected_tokens: &["--user", "--group"], + }, + HelpCase { + args: &["admin", "group", "ls"], + usage: "Usage: rc admin group ls [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "group", "add"], + usage: "Usage: rc admin group add [OPTIONS] ", + expected_tokens: &["--members"], + }, + HelpCase { + args: &["admin", "group", "info"], + usage: "Usage: rc admin group info [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "group", "rm"], + usage: "Usage: rc admin group rm [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "group", "enable"], + usage: "Usage: rc admin group enable [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "group", "disable"], + usage: "Usage: rc admin group disable [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "group", "add-members"], + usage: "Usage: rc admin group add-members [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "group", "rm-members"], + usage: "Usage: rc admin group rm-members [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "service-account", "ls"], + usage: "Usage: rc admin service-account ls [OPTIONS] ", + expected_tokens: &["--user"], + }, + HelpCase { + args: &["admin", "service-account", "create"], + usage: "Usage: rc admin service-account create [OPTIONS] ", + expected_tokens: &["--name", "--description", "--policy", "--expiry"], + }, + HelpCase { + args: &["admin", "service-account", "info"], + usage: "Usage: rc admin service-account info [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["admin", "service-account", "rm"], + usage: "Usage: rc admin service-account rm [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["version", "enable"], + usage: "Usage: rc version enable [OPTIONS] ", + expected_tokens: &["--force"], + }, + HelpCase { + args: &["version", "suspend"], + usage: "Usage: rc version suspend [OPTIONS] ", + expected_tokens: &["--force"], + }, + HelpCase { + args: &["version", "info"], + usage: "Usage: rc version info [OPTIONS] ", + expected_tokens: &["--force"], + }, + HelpCase { + args: &["version", "list"], + usage: "Usage: rc version list [OPTIONS] ", + expected_tokens: &["--max", "--force"], + }, + HelpCase { + args: &["tag", "list"], + usage: "Usage: rc tag list [OPTIONS] ", + expected_tokens: &["--force"], + }, + HelpCase { + args: &["tag", "set"], + usage: "Usage: rc tag set [OPTIONS] ", + expected_tokens: &["--tags", "--force"], + }, + HelpCase { + args: &["tag", "remove"], + usage: "Usage: rc tag remove [OPTIONS] ", + expected_tokens: &["--force"], + }, + HelpCase { + args: &["quota", "set"], + usage: "Usage: rc quota set [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["quota", "info"], + usage: "Usage: rc quota info [OPTIONS] ", + expected_tokens: &[], + }, + HelpCase { + args: &["quota", "clear"], + usage: "Usage: rc quota clear [OPTIONS] ", + expected_tokens: &[], + }, + ]; + + for case in cases { + assert_help_case(&case); + } +}