feat(error): add structured exit codes for scriptable error handling (#428)

* feat(error): add structured exit codes for scriptable error handling

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

* 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.

* refactor(error): centralize exit code help text via EXIT_CODE_DOCUMENTATION

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.
This commit is contained in:
Abhi Ram Reddy Salammagari
2026-03-12 09:02:47 -07:00
committed by GitHub
parent 087066f946
commit 247e27a876
4 changed files with 147 additions and 1 deletions
+18
View File
@@ -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.
+22
View File
@@ -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:
+101
View File
@@ -39,7 +39,51 @@ 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;
/// 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 |
/// |------|----------------------------------------------|
/// | 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 { .. } => 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,
}
}
pub fn to_json(&self) -> serde_json::Value {
match self {
GwsError::Api {
@@ -126,6 +170,63 @@ 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(), GwsError::EXIT_CODE_API);
}
#[test]
fn test_exit_code_auth() {
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(),
GwsError::EXIT_CODE_VALIDATION
);
}
#[test]
fn test_exit_code_discovery() {
assert_eq!(
GwsError::Discovery("fetch failed".to_string()).exit_code(),
GwsError::EXIT_CODE_DISCOVERY
);
}
#[test]
fn test_exit_code_other() {
assert_eq!(
GwsError::Other(anyhow::anyhow!("oops")).exit_code(),
GwsError::EXIT_CODE_OTHER
);
}
#[test]
fn test_exit_codes_are_distinct() {
// Ensure all named constants are unique (regression guard).
let codes = [
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<i32> = 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 {
+6 -1
View File
@@ -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,11 @@ fn print_usage() {
" GOOGLE_WORKSPACE_PROJECT_ID Override the GCP project ID for quota and billing"
);
println!();
println!("EXIT CODES:");
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");
println!(" Report bugs / request features: https://github.com/googleworkspace/cli/issues");