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..6f6e0b67 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 = 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..9857daf4 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,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");