Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/structured-exit-codes.md
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}

Expand Down Expand Up @@ -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");
Expand Down
Loading