refactor: replace manual arg parsing in auth commands with clap (#594)

* refactor: replace manual arg parsing in auth commands with clap

- Add ScopeMode enum (Default, Readonly, Full, Custom) for type-safe
  scope selection
- Build auth_command() with clap subcommands: login, setup, status,
  export, logout
- Replace manual --help check and match dispatch in handle_auth_command
  with clap subcommand routing
- Replace two-pass manual parsing in handle_login (services extraction)
  and resolve_scopes (scopes/readonly/full flags) with single clap parse
- Add --unmasked flag to export subcommand via clap
- Preserve run_login(&[]) public API for setup.rs compatibility
- Update all 63 tests to use ScopeMode-based API

* refactor: extract parse_login_args helper to deduplicate scope/services parsing

* fix: make --readonly, --full, --scopes mutually exclusive via clap conflicts

* fix: filter empty strings from custom scopes parsing

* refactor: extract build_login_subcommand to eliminate fragile .expect() lookup

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-23 14:25:42 -06:00
committed by GitHub
parent d341de29a2
commit a078945f2a
2 changed files with 240 additions and 142 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@googleworkspace/cli": minor
---
Refactor all `gws auth` subcommands to use clap for argument parsing
Replace manual argument parsing in `handle_auth_command`, `handle_login`, `resolve_scopes`, and `handle_export` with structured `clap::Command` definitions. Introduces `ScopeMode` enum for type-safe scope selection and adds proper `--help` support for all auth subcommands.
+233 -142
View File
@@ -130,51 +130,196 @@ fn token_cache_path() -> PathBuf {
config_dir().join("token_cache.json")
}
/// Which scope set to use for login.
enum ScopeMode {
/// Use the default scopes (MINIMAL_SCOPES).
Default,
/// Use readonly scopes.
Readonly,
/// Use full scopes (incl. pubsub + cloud-platform).
Full,
/// Use explicitly provided custom scopes.
Custom(Vec<String>),
}
/// Build the clap Command for the `login` subcommand.
/// Used by both `auth_command()` and `login_command()` as single source of truth.
fn build_login_subcommand() -> clap::Command {
clap::Command::new("login")
.about("Authenticate via OAuth2 (opens browser)")
.arg(
clap::Arg::new("readonly")
.long("readonly")
.help("Request read-only scopes")
.action(clap::ArgAction::SetTrue)
.conflicts_with_all(["full", "scopes"]),
)
.arg(
clap::Arg::new("full")
.long("full")
.help("Request all scopes incl. pubsub + cloud-platform")
.action(clap::ArgAction::SetTrue)
.conflicts_with_all(["readonly", "scopes"]),
)
.arg(
clap::Arg::new("scopes")
.long("scopes")
.help("Comma-separated custom scopes")
.value_name("scopes")
.conflicts_with_all(["readonly", "full"]),
)
.arg(
clap::Arg::new("services")
.short('s')
.long("services")
.help(
"Comma-separated service names to limit scope picker (e.g. drive,gmail,sheets)",
)
.value_name("services"),
)
}
/// Build the clap Command for `gws auth`.
fn auth_command() -> clap::Command {
clap::Command::new("auth")
.about("Manage authentication for Google Workspace APIs")
.subcommand_required(false)
.subcommand(build_login_subcommand())
.subcommand(
clap::Command::new("setup")
.about("Configure GCP project + OAuth client (requires gcloud)")
.disable_help_flag(true)
// setup has its own clap-based arg parsing in setup.rs,
// so we pass remaining args through.
.arg(
clap::Arg::new("args")
.trailing_var_arg(true)
.allow_hyphen_values(true)
.num_args(0..)
.value_name("ARGS"),
),
)
.subcommand(clap::Command::new("status").about("Show current authentication state"))
.subcommand(
clap::Command::new("export")
.about("Print decrypted credentials to stdout")
.arg(
clap::Arg::new("unmasked")
.long("unmasked")
.help("Show secrets without masking")
.action(clap::ArgAction::SetTrue),
),
)
.subcommand(clap::Command::new("logout").about("Clear saved credentials and token cache"))
}
/// Handle `gws auth <subcommand>`.
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
const USAGE: &str = concat!(
"Usage: gws auth <login|setup|status|export|logout> [options]\n\n",
" login Authenticate via OAuth2 (opens browser)\n",
" --readonly Request read-only scopes\n",
" --full Request all scopes incl. pubsub + cloud-platform\n",
" (may trigger restricted_client for unverified apps)\n",
" --scopes Comma-separated custom scopes\n",
" -s, --services Comma-separated service names to limit scope picker\n",
" (e.g. -s drive,gmail,sheets)\n",
" setup Configure GCP project + OAuth client (requires gcloud)\n",
" --project Use a specific GCP project\n",
" --login Run `gws auth login` after successful setup\n",
" status Show current authentication state\n",
" export Print decrypted credentials to stdout\n",
" logout Clear saved credentials and token cache",
);
let matches = match auth_command()
.try_get_matches_from(std::iter::once("auth".to_string()).chain(args.iter().cloned()))
{
Ok(m) => m,
Err(e)
if e.kind() == clap::error::ErrorKind::DisplayHelp
|| e.kind() == clap::error::ErrorKind::DisplayVersion =>
{
e.print().map_err(|io_err| {
GwsError::Validation(format!("Failed to print help: {io_err}"))
})?;
return Ok(());
}
Err(e) => return Err(GwsError::Validation(e.to_string())),
};
// Honour --help / -h before treating the first arg as a subcommand.
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
println!("{USAGE}");
return Ok(());
}
match matches.subcommand() {
Some(("login", sub_m)) => {
let (scope_mode, services_filter) = parse_login_args(sub_m);
match args[0].as_str() {
"login" => run_login(&args[1..]).await,
"setup" => crate::setup::run_setup(&args[1..]).await,
"status" => handle_status().await,
"export" => {
let unmasked = args.len() > 1 && args[1] == "--unmasked";
handle_login_inner(scope_mode, services_filter).await
}
Some(("setup", sub_m)) => {
// Collect remaining args and delegate to setup's own clap parser.
let setup_args: Vec<String> = sub_m
.get_many::<String>("args")
.map(|vals| vals.cloned().collect())
.unwrap_or_default();
crate::setup::run_setup(&setup_args).await
}
Some(("status", _)) => handle_status().await,
Some(("export", sub_m)) => {
let unmasked = sub_m.get_flag("unmasked");
handle_export(unmasked).await
}
"logout" => handle_logout(),
other => Err(GwsError::Validation(format!(
"Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout"
))),
Some(("logout", _)) => handle_logout(),
_ => {
// No subcommand → print help
auth_command()
.print_help()
.map_err(|e| GwsError::Validation(format!("Failed to print help: {e}")))?;
Ok(())
}
}
}
/// Build the clap Command for `gws auth login` (used by `run_login` for
/// standalone parsing when called from setup.rs).
fn login_command() -> clap::Command {
build_login_subcommand()
}
/// Extract `ScopeMode` and optional services filter from parsed login args.
fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option<HashSet<String>>) {
let scope_mode = if let Some(scopes_str) = matches.get_one::<String>("scopes") {
ScopeMode::Custom(
scopes_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
)
} else if matches.get_flag("readonly") {
ScopeMode::Readonly
} else if matches.get_flag("full") {
ScopeMode::Full
} else {
ScopeMode::Default
};
let services_filter: Option<HashSet<String>> = matches.get_one::<String>("services").map(|v| {
v.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
});
(scope_mode, services_filter)
}
/// Run the `auth login` flow.
///
/// Exposed for internal orchestration (e.g. `auth setup --login`).
/// Accepts raw args for backward compat with setup.rs calling `run_login(&[])`.
pub async fn run_login(args: &[String]) -> Result<(), GwsError> {
handle_login(args).await
let matches = match login_command()
.try_get_matches_from(std::iter::once("login".to_string()).chain(args.iter().cloned()))
{
Ok(m) => m,
Err(e)
if e.kind() == clap::error::ErrorKind::DisplayHelp
|| e.kind() == clap::error::ErrorKind::DisplayVersion =>
{
e.print().map_err(|io_err| {
GwsError::Validation(format!("Failed to print help: {io_err}"))
})?;
return Ok(());
}
Err(e) => return Err(GwsError::Validation(e.to_string())),
};
let (scope_mode, services_filter) = parse_login_args(&matches);
handle_login_inner(scope_mode, services_filter).await
}
/// Custom delegate that prints the OAuth URL on its own line for easy copying.
/// Optionally includes `login_hint` in the URL for account pre-selection.
@@ -212,36 +357,11 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
}
}
async fn handle_login(args: &[String]) -> Result<(), GwsError> {
// Extract -s/--services from args
let mut services_filter: Option<HashSet<String>> = None;
let mut filtered_args: Vec<String> = Vec::new();
let mut skip_next = false;
for i in 0..args.len() {
if skip_next {
skip_next = false;
continue;
}
let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() {
skip_next = true;
Some(args[i + 1].as_str())
} else {
args[i].strip_prefix("--services=")
};
if let Some(value) = services_str {
services_filter = Some(
value
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
);
continue;
}
filtered_args.push(args[i].clone());
}
/// Inner login implementation that takes already-parsed options.
async fn handle_login_inner(
scope_mode: ScopeMode,
services_filter: Option<HashSet<String>>,
) -> Result<(), GwsError> {
// Resolve client_id and client_secret:
// 1. Env vars (highest priority)
// 2. Saved client_secret.json from `gws auth setup` or manual download
@@ -258,12 +378,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
}
// Determine scopes: explicit flags > interactive TUI > defaults
let scopes = resolve_scopes(
&filtered_args,
project_id.as_deref(),
services_filter.as_ref(),
)
.await;
let scopes = resolve_scopes(scope_mode, project_id.as_deref(), services_filter.as_ref()).await;
// Remove restrictive scopes when broader alternatives are present.
let mut scopes = filter_redundant_restrictive_scopes(scopes);
@@ -479,32 +594,25 @@ fn resolve_client_credentials() -> Result<(String, String, Option<String>), GwsE
/// When `services_filter` is `Some`, only scopes belonging to the specified
/// services are shown in the picker (or returned in non-interactive mode).
async fn resolve_scopes(
args: &[String],
scope_mode: ScopeMode,
project_id: Option<&str>,
services_filter: Option<&HashSet<String>>,
) -> Vec<String> {
// Explicit --scopes flag takes priority (bypasses services filter)
for i in 0..args.len() {
if args[i] == "--scopes" && i + 1 < args.len() {
return args[i + 1]
.split(',')
.map(|s| s.trim().to_string())
.collect();
match scope_mode {
ScopeMode::Custom(scopes) => return scopes,
ScopeMode::Readonly => {
let scopes: Vec<String> = READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
let mut result = filter_scopes_by_services(scopes, services_filter);
augment_with_dynamic_scopes(&mut result, services_filter, true).await;
return result;
}
}
let readonly_only = args.iter().any(|a| a == "--readonly");
if readonly_only {
let scopes: Vec<String> = READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
let mut result = filter_scopes_by_services(scopes, services_filter);
augment_with_dynamic_scopes(&mut result, services_filter, true).await;
return result;
}
if args.iter().any(|a| a == "--full") {
let scopes: Vec<String> = FULL_SCOPES.iter().map(|s| s.to_string()).collect();
let mut result = filter_scopes_by_services(scopes, services_filter);
augment_with_dynamic_scopes(&mut result, services_filter, false).await;
return result;
ScopeMode::Full => {
let scopes: Vec<String> = FULL_SCOPES.iter().map(|s| s.to_string()).collect();
let mut result = filter_scopes_by_services(scopes, services_filter);
augment_with_dynamic_scopes(&mut result, services_filter, false).await;
return result;
}
ScopeMode::Default => {} // fall through to interactive picker / defaults
}
// Interactive scope picker when running in a TTY
@@ -1453,66 +1561,58 @@ mod tests {
use super::*;
/// Helper to run resolve_scopes in tests (async).
fn run_resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String> {
fn run_resolve_scopes(scope_mode: ScopeMode, project_id: Option<&str>) -> Vec<String> {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(resolve_scopes(args, project_id, None))
rt.block_on(resolve_scopes(scope_mode, project_id, None))
}
/// Helper to run resolve_scopes with a services filter.
fn run_resolve_scopes_with_services(
args: &[String],
scope_mode: ScopeMode,
project_id: Option<&str>,
services: &[&str],
) -> Vec<String> {
let filter: HashSet<String> = services.iter().map(|s| s.to_string()).collect();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(resolve_scopes(args, project_id, Some(&filter)))
rt.block_on(resolve_scopes(scope_mode, project_id, Some(&filter)))
}
#[test]
fn resolve_scopes_returns_defaults_when_no_flag() {
let args: Vec<String> = vec![];
let scopes = run_resolve_scopes(&args, None);
let scopes = run_resolve_scopes(ScopeMode::Default, None);
assert_eq!(scopes.len(), DEFAULT_SCOPES.len());
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
}
#[test]
fn resolve_scopes_returns_custom_scopes() {
let args: Vec<String> = vec![
"--scopes".to_string(),
"https://www.googleapis.com/auth/drive.readonly".to_string(),
];
let scopes = run_resolve_scopes(&args, None);
let scopes = run_resolve_scopes(
ScopeMode::Custom(vec![
"https://www.googleapis.com/auth/drive.readonly".to_string()
]),
None,
);
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive.readonly");
}
#[test]
fn resolve_scopes_handles_multiple_comma_separated() {
let args: Vec<String> = vec![
"--scopes".to_string(),
"https://www.googleapis.com/auth/drive, https://www.googleapis.com/auth/gmail.readonly"
.to_string(),
];
let scopes = run_resolve_scopes(&args, None);
fn resolve_scopes_handles_multiple_custom() {
let scopes = run_resolve_scopes(
ScopeMode::Custom(vec![
"https://www.googleapis.com/auth/drive".to_string(),
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
]),
None,
);
assert_eq!(scopes.len(), 2);
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
assert_eq!(scopes[1], "https://www.googleapis.com/auth/gmail.readonly");
}
#[test]
fn resolve_scopes_ignores_trailing_flag() {
// --scopes with no value should use defaults
let args: Vec<String> = vec!["--scopes".to_string()];
let scopes = run_resolve_scopes(&args, None);
assert_eq!(scopes.len(), DEFAULT_SCOPES.len());
}
#[test]
fn resolve_scopes_readonly_returns_readonly_scopes() {
let args = vec!["--readonly".to_string()];
let scopes = run_resolve_scopes(&args, None);
let scopes = run_resolve_scopes(ScopeMode::Readonly, None);
assert_eq!(scopes.len(), READONLY_SCOPES.len());
for scope in &scopes {
assert!(
@@ -1523,16 +1623,9 @@ mod tests {
}
#[test]
fn resolve_scopes_custom_overrides_readonly() {
// --scopes takes priority over --readonly
let args = vec![
"--scopes".to_string(),
"https://www.googleapis.com/auth/drive".to_string(),
"--readonly".to_string(),
];
let scopes = run_resolve_scopes(&args, None);
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
fn resolve_scopes_full_returns_full_scopes() {
let scopes = run_resolve_scopes(ScopeMode::Full, None);
assert_eq!(scopes.len(), FULL_SCOPES.len());
}
#[test]
@@ -1984,8 +2077,8 @@ mod tests {
#[test]
fn resolve_scopes_with_services_filter() {
let args: Vec<String> = vec![];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "gmail"]);
let scopes =
run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "gmail"]);
assert!(!scopes.is_empty());
for scope in &scopes {
let short = scope
@@ -2002,8 +2095,8 @@ mod tests {
#[test]
fn resolve_scopes_services_filter_unknown_service_ignored() {
let args: Vec<String> = vec![];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "nonexistent"]);
let scopes =
run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "nonexistent"]);
assert!(!scopes.is_empty());
// Should contain drive scope but not be affected by nonexistent
assert!(scopes.iter().any(|s| s.contains("/auth/drive")));
@@ -2011,8 +2104,7 @@ mod tests {
#[test]
fn resolve_scopes_services_takes_priority_with_readonly() {
let args = vec!["--readonly".to_string()];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]);
let scopes = run_resolve_scopes_with_services(ScopeMode::Readonly, None, &["drive"]);
assert!(!scopes.is_empty());
for scope in &scopes {
let short = scope
@@ -2027,8 +2119,7 @@ mod tests {
#[test]
fn resolve_scopes_services_takes_priority_with_full() {
let args = vec!["--full".to_string()];
let scopes = run_resolve_scopes_with_services(&args, None, &["gmail"]);
let scopes = run_resolve_scopes_with_services(ScopeMode::Full, None, &["gmail"]);
assert!(!scopes.is_empty());
for scope in &scopes {
let short = scope
@@ -2043,12 +2134,12 @@ mod tests {
#[test]
fn resolve_scopes_explicit_scopes_bypass_services_filter() {
// --scopes should take priority over -s
let args = vec![
"--scopes".to_string(),
"https://www.googleapis.com/auth/calendar".to_string(),
];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]);
// Custom scopes take priority over services filter
let scopes = run_resolve_scopes_with_services(
ScopeMode::Custom(vec!["https://www.googleapis.com/auth/calendar".to_string()]),
None,
&["drive"],
);
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar");
}