From 933add1282736e03aa1e45ae27982423b04b46dd Mon Sep 17 00:00:00 2001 From: abhiram304 Date: Thu, 12 Mar 2026 00:27:08 -0700 Subject: [PATCH 1/3] feat(error): add structured exit codes for scriptable error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded `std::process::exit(1)` with a type-specific exit code derived from the GwsError variant: 0 — success 1 — API error (GwsError::Api) 2 — auth error (GwsError::Auth) 3 — validation (GwsError::Validation) 4 — discovery (GwsError::Discovery) 5 — internal (GwsError::Other) This allows shell scripts to branch on failure type without parsing the JSON error output: gws drive files list ... case $? in 1) echo "API error — check your params" ;; 2) echo "Auth error — run: gws auth login" ;; 3) echo "Bad arguments" ;; esac Changes: - Add GwsError::exit_code() mapping variants to codes - Update main() to call std::process::exit(err.exit_code()) - Document exit codes in gws --help (print_usage) - Document exit codes in README under new Exit Codes section - Add 6 unit tests including a regression guard asserting all codes are distinct --- .changeset/structured-exit-codes.md | 18 +++++++ README.md | 22 +++++++++ src/error.rs | 74 +++++++++++++++++++++++++++++ src/main.rs | 10 +++- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .changeset/structured-exit-codes.md diff --git a/.changeset/structured-exit-codes.md b/.changeset/structured-exit-codes.md new file mode 100644 index 00000000..b1b0f35a --- /dev/null +++ b/.changeset/structured-exit-codes.md @@ -0,0 +1,18 @@ +--- +"@googleworkspace/cli": minor +--- + +Add structured exit codes for scriptable error handling + +`gws` now exits with a type-specific code instead of always using `1`: + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | API error — Google returned a 4xx/5xx response | +| `2` | Auth error — credentials missing, expired, or invalid | +| `3` | Validation error — bad arguments, unknown service, invalid flag | +| `4` | Discovery error — could not fetch the API schema document | +| `5` | Internal error — unexpected failure | + +Exit codes are documented in `gws --help` and in the README. diff --git a/README.md b/README.md index 9130ecbd..cc4cdf17 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ npm install -g @googleworkspace/cli - [AI Agent Skills](#ai-agent-skills) - [Advanced Usage](#advanced-usage) - [Environment Variables](#environment-variables) +- [Exit Codes](#exit-codes) - [Architecture](#architecture) - [Troubleshooting](#troubleshooting) - [Development](#development) @@ -317,6 +318,27 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste Environment variables can also be set in a `.env` file (loaded via [dotenvy](https://crates.io/crates/dotenvy)). +## Exit Codes + +`gws` uses structured exit codes so scripts can branch on the failure type without parsing error output. + +| Code | Meaning | Example cause | +|------|---------|---------------| +| `0` | Success | Command completed normally | +| `1` | API error | Google returned a 4xx/5xx response | +| `2` | Auth error | Credentials missing, expired, or invalid | +| `3` | Validation error | Bad arguments, unknown service, invalid flag | +| `4` | Discovery error | Could not fetch the API schema document | +| `5` | Internal error | Unexpected failure | + +```bash +gws drive files list --params '{"fileId": "bad"}' +echo $? # 1 — API error + +gws unknown-service files list +echo $? # 3 — validation error (unknown service) +``` + ## Architecture `gws` uses a **two-phase parsing** strategy: diff --git a/src/error.rs b/src/error.rs index 25cc9f59..48fb3c4b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,6 +40,26 @@ pub enum GwsError { } impl GwsError { + /// Map each error variant to a stable, documented exit code. + /// + /// | Code | Meaning | + /// |------|----------------------------------------------| + /// | 0 | Success (never returned here) | + /// | 1 | API error — Google returned an error response | + /// | 2 | Auth error — credentials missing or invalid | + /// | 3 | Validation error — bad arguments or input | + /// | 4 | Discovery error — could not fetch API schema | + /// | 5 | Internal error — unexpected failure | + pub fn exit_code(&self) -> i32 { + match self { + GwsError::Api { .. } => 1, + GwsError::Auth(_) => 2, + GwsError::Validation(_) => 3, + GwsError::Discovery(_) => 4, + GwsError::Other(_) => 5, + } + } + pub fn to_json(&self) -> serde_json::Value { match self { GwsError::Api { @@ -126,6 +146,60 @@ pub fn print_error_json(err: &GwsError) { mod tests { use super::*; + #[test] + fn test_exit_code_api() { + let err = GwsError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 1); + } + + #[test] + fn test_exit_code_auth() { + assert_eq!(GwsError::Auth("bad token".to_string()).exit_code(), 2); + } + + #[test] + fn test_exit_code_validation() { + assert_eq!( + GwsError::Validation("missing arg".to_string()).exit_code(), + 3 + ); + } + + #[test] + fn test_exit_code_discovery() { + assert_eq!( + GwsError::Discovery("fetch failed".to_string()).exit_code(), + 4 + ); + } + + #[test] + fn test_exit_code_other() { + assert_eq!( + GwsError::Other(anyhow::anyhow!("oops")).exit_code(), + 5 + ); + } + + #[test] + fn test_exit_codes_are_distinct() { + // Ensure no two variants share an exit code (regression guard). + let codes = [ + GwsError::Api { code: 500, message: String::new(), reason: String::new(), enable_url: None }.exit_code(), + GwsError::Auth(String::new()).exit_code(), + GwsError::Validation(String::new()).exit_code(), + GwsError::Discovery(String::new()).exit_code(), + GwsError::Other(anyhow::anyhow!("")).exit_code(), + ]; + let unique: std::collections::HashSet = codes.iter().copied().collect(); + assert_eq!(unique.len(), codes.len(), "exit codes must be distinct: {codes:?}"); + } + #[test] fn test_error_to_json_api() { let err = GwsError::Api { diff --git a/src/main.rs b/src/main.rs index 22259a44..7d09387e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,7 +49,7 @@ async fn main() { if let Err(err) = run().await { print_error_json(&err); - std::process::exit(1); + std::process::exit(err.exit_code()); } } @@ -460,6 +460,14 @@ fn print_usage() { " GOOGLE_WORKSPACE_PROJECT_ID Override the GCP project ID for quota and billing" ); println!(); + println!("EXIT CODES:"); + println!(" 0 Success"); + println!(" 1 API error — Google returned an error response"); + println!(" 2 Auth error — credentials missing or invalid"); + println!(" 3 Validation — bad arguments or input"); + println!(" 4 Discovery — could not fetch API schema"); + println!(" 5 Internal — unexpected failure"); + println!(); println!("COMMUNITY:"); println!(" Star the repo: https://github.com/googleworkspace/cli"); println!(" Report bugs / request features: https://github.com/googleworkspace/cli/issues"); From 71d1ae493535b861d8ccd22ff6d590fef7fd5bb5 Mon Sep 17 00:00:00 2001 From: abhiram304 Date: Thu, 12 Mar 2026 00:40:21 -0700 Subject: [PATCH 2/3] refactor(error): replace magic exit-code numbers with named constants Add EXIT_CODE_API/AUTH/VALIDATION/DISCOVERY/OTHER associated constants on GwsError so callers and tests reference symbolic names rather than bare integers. Update exit_code() match arms and all tests accordingly. The distinctness test now validates the constants array directly. Addresses code-review feedback requesting named constants. --- src/error.rs | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/error.rs b/src/error.rs index 48fb3c4b..2bb8b452 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,6 +40,17 @@ pub enum GwsError { } impl GwsError { + /// Exit code for [`GwsError::Api`] variants. + pub const EXIT_CODE_API: i32 = 1; + /// Exit code for [`GwsError::Auth`] variants. + pub const EXIT_CODE_AUTH: i32 = 2; + /// Exit code for [`GwsError::Validation`] variants. + pub const EXIT_CODE_VALIDATION: i32 = 3; + /// Exit code for [`GwsError::Discovery`] variants. + pub const EXIT_CODE_DISCOVERY: i32 = 4; + /// Exit code for [`GwsError::Other`] variants. + pub const EXIT_CODE_OTHER: i32 = 5; + /// Map each error variant to a stable, documented exit code. /// /// | Code | Meaning | @@ -52,11 +63,11 @@ impl GwsError { /// | 5 | Internal error — unexpected failure | pub fn exit_code(&self) -> i32 { match self { - GwsError::Api { .. } => 1, - GwsError::Auth(_) => 2, - GwsError::Validation(_) => 3, - GwsError::Discovery(_) => 4, - GwsError::Other(_) => 5, + GwsError::Api { .. } => Self::EXIT_CODE_API, + GwsError::Auth(_) => Self::EXIT_CODE_AUTH, + GwsError::Validation(_) => Self::EXIT_CODE_VALIDATION, + GwsError::Discovery(_) => Self::EXIT_CODE_DISCOVERY, + GwsError::Other(_) => Self::EXIT_CODE_OTHER, } } @@ -154,19 +165,22 @@ mod tests { reason: "notFound".to_string(), enable_url: None, }; - assert_eq!(err.exit_code(), 1); + assert_eq!(err.exit_code(), GwsError::EXIT_CODE_API); } #[test] fn test_exit_code_auth() { - assert_eq!(GwsError::Auth("bad token".to_string()).exit_code(), 2); + assert_eq!( + GwsError::Auth("bad token".to_string()).exit_code(), + GwsError::EXIT_CODE_AUTH + ); } #[test] fn test_exit_code_validation() { assert_eq!( GwsError::Validation("missing arg".to_string()).exit_code(), - 3 + GwsError::EXIT_CODE_VALIDATION ); } @@ -174,7 +188,7 @@ mod tests { fn test_exit_code_discovery() { assert_eq!( GwsError::Discovery("fetch failed".to_string()).exit_code(), - 4 + GwsError::EXIT_CODE_DISCOVERY ); } @@ -182,19 +196,19 @@ mod tests { fn test_exit_code_other() { assert_eq!( GwsError::Other(anyhow::anyhow!("oops")).exit_code(), - 5 + GwsError::EXIT_CODE_OTHER ); } #[test] fn test_exit_codes_are_distinct() { - // Ensure no two variants share an exit code (regression guard). + // Ensure all named constants are unique (regression guard). let codes = [ - GwsError::Api { code: 500, message: String::new(), reason: String::new(), enable_url: None }.exit_code(), - GwsError::Auth(String::new()).exit_code(), - GwsError::Validation(String::new()).exit_code(), - GwsError::Discovery(String::new()).exit_code(), - GwsError::Other(anyhow::anyhow!("")).exit_code(), + GwsError::EXIT_CODE_API, + GwsError::EXIT_CODE_AUTH, + GwsError::EXIT_CODE_VALIDATION, + GwsError::EXIT_CODE_DISCOVERY, + GwsError::EXIT_CODE_OTHER, ]; let unique: std::collections::HashSet = codes.iter().copied().collect(); assert_eq!(unique.len(), codes.len(), "exit codes must be distinct: {codes:?}"); From be31c315967b6436fabd37c20c12c150732b753c Mon Sep 17 00:00:00 2001 From: abhiram304 Date: Thu, 12 Mar 2026 08:42:19 -0700 Subject: [PATCH 3/3] refactor(error): centralize exit code help text via EXIT_CODE_DOCUMENTATION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a module-level EXIT_CODE_DOCUMENTATION constant — a static slice of (code, description) pairs built from the EXIT_CODE_* constants. Replace the hardcoded println! block in print_usage() with a loop over this slice so the help output is always in sync with the defined constants and cannot drift out of date. Addresses code-review feedback requesting a single source of truth. --- src/error.rs | 13 +++++++++++++ src/main.rs | 9 +++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/error.rs b/src/error.rs index 2bb8b452..6f6e0b67 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,6 +39,19 @@ pub enum GwsError { Other(#[from] anyhow::Error), } +/// Human-readable exit code table, keyed by (code, description). +/// +/// Used by `print_usage()` so the help text stays in sync with the +/// constants defined below without requiring manual updates in two places. +pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[ + (0, "Success"), + (GwsError::EXIT_CODE_API, "API error — Google returned an error response"), + (GwsError::EXIT_CODE_AUTH, "Auth error — credentials missing or invalid"), + (GwsError::EXIT_CODE_VALIDATION, "Validation — bad arguments or input"), + (GwsError::EXIT_CODE_DISCOVERY, "Discovery — could not fetch API schema"), + (GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"), +]; + impl GwsError { /// Exit code for [`GwsError::Api`] variants. pub const EXIT_CODE_API: i32 = 1; diff --git a/src/main.rs b/src/main.rs index 7d09387e..9857daf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -461,12 +461,9 @@ fn print_usage() { ); println!(); println!("EXIT CODES:"); - println!(" 0 Success"); - println!(" 1 API error — Google returned an error response"); - println!(" 2 Auth error — credentials missing or invalid"); - println!(" 3 Validation — bad arguments or input"); - println!(" 4 Discovery — could not fetch API schema"); - println!(" 5 Internal — unexpected failure"); + for (code, description) in crate::error::EXIT_CODE_DOCUMENTATION { + println!(" {:<5}{}", code, description); + } println!(); println!("COMMUNITY:"); println!(" Star the repo: https://github.com/googleworkspace/cli");