chore: import upstream snapshot with attribution
Publish OpenClaw Skills / publish (push) Has been cancelled
Audit / Security Audit (push) Has been cancelled
Automation / Gemini Review (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Nix (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Cargo Deny (push) Has been cancelled
CI / Verify Skills (push) Has been cancelled
CI / Lint Skills (push) Has been cancelled
CI / Build (Linux x86_64) (push) Has been cancelled
CI / Build (macos-latest, aarch64-apple-darwin) (push) Has been cancelled
CI / Build (macos-latest, x86_64-apple-darwin) (push) Has been cancelled
CI / Build (ubuntu-latest, aarch64-unknown-linux-gnu) (push) Has been cancelled
CI / Build (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / API Smoketest (push) Has been cancelled
Policy / Policy Check (push) Has been cancelled
Release (Changeset) / Release (push) Has been cancelled
Automation / Gemini Reviewed (push) Has been cancelled
Coverage / Coverage (push) Has been cancelled
Automation / Format (push) Has been cancelled
Automation / File Labeler (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:06 +08:00
commit 76a9e7a0cc
222 changed files with 47091 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[package]
name = "google-workspace-cli"
version = "0.22.5"
edition = "2021"
description = "Google Workspace CLI — dynamic command surface from Discovery Service"
license = "Apache-2.0"
repository = "https://github.com/googleworkspace/cli"
homepage = "https://github.com/googleworkspace/cli"
readme = "README.md"
authors = ["Justin Poehnelt"]
keywords = ["cli", "google-workspace", "google", "drive", "gmail"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "gws"
path = "src/main.rs"
[dependencies]
google-workspace = { version = "0.22.5", path = "../google-workspace" }
tempfile = "3"
aes-gcm = "0.10"
anyhow = "1"
clap = { version = "4", features = ["derive", "string"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
yup-oauth2 = "12"
futures-util = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
base64 = "0.22.1"
derive_builder = "0.20.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
mail-builder = "0.4"
async-trait = "0.1.89"
toml = "0.8"
percent-encoding = "2.3.2"
zeroize = { version = "1.8.2", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
uuid = { version = "1.22.0", features = ["v4", "v5"] }
mime_guess2 = "2.3.1"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
keyring = "3.6.3"
[dev-dependencies]
serial_test = "3.4.0"
+33
View File
@@ -0,0 +1,33 @@
# google-workspace-cli
**One CLI for all of Google Workspace — built for humans and AI agents.**
`gws` dynamically generates its command surface at runtime by reading Google's [Discovery Service](https://developers.google.com/discovery). Drive, Gmail, Calendar, and every Workspace API — zero boilerplate, structured JSON output, 40+ agent skills included.
## Install
Download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page.
Alternatively, you can use package managers as a convenience layer:
```bash
npm install -g @googleworkspace/cli # npm (downloads GitHub release binary)
cargo install google-workspace-cli # crates.io
nix run github:googleworkspace/cli # nix
```
## Quick Start
```bash
gws auth login
gws drive files list --params '{"pageSize": 5}'
gws gmail users.messages list --params '{"maxResults": 3}'
```
## Documentation
See the [full README](https://github.com/googleworkspace/cli#readme) for authentication setup, helper commands, agent skills, and more.
## License
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
@@ -0,0 +1,190 @@
[[personas]]
name = "exec-assistant"
title = "Executive Assistant"
description = "Manage an executive's schedule, inbox, and communications."
services = [ "gmail", "calendar", "drive", "chat" ]
workflows = [ "+standup-report", "+meeting-prep", "+weekly-digest" ]
instructions = [
"Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks.",
"Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs.",
"Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership.",
"Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`.",
"Draft replies with `gws gmail +send` — keep tone professional and concise."
]
tips = [
"Always confirm calendar changes with the executive before committing.",
"Use `--format table` for quick visual scans of agenda and triage output.",
"Check `gws calendar +agenda --week` on Monday mornings for weekly planning."
]
[[personas]]
name = "project-manager"
title = "Project Manager"
description = "Coordinate projects — track tasks, schedule meetings, and share docs."
services = [ "drive", "sheets", "calendar", "gmail", "chat" ]
workflows = [ "+standup-report", "+weekly-digest", "+file-announce" ]
instructions = [
"Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items.",
"Track project status in Sheets using `gws sheets +append` to log updates.",
"Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`.",
"Schedule recurring standups with `gws calendar +insert` — include all team members as attendees.",
"Send status update emails to stakeholders with `gws gmail +send`."
]
tips = [
"Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders.",
"Pipe triage output through `jq` for filtering by sender or subject.",
"Use `--dry-run` before any write operations to preview what will happen."
]
[[personas]]
name = "hr-coordinator"
title = "HR Coordinator"
description = "Handle HR workflows — onboarding, announcements, and employee comms."
services = [ "gmail", "calendar", "drive", "chat" ]
workflows = [ "+email-to-task", "+file-announce" ]
instructions = [
"For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`.",
"Upload onboarding docs to a shared Drive folder with `gws drive +upload`.",
"Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc.",
"Convert email requests into tracked tasks with `gws workflow +email-to-task`.",
"Send bulk announcements with `gws gmail +send` — use clear subject lines."
]
tips = [
"Always use `--sanitize` for PII-sensitive operations.",
"Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules."
]
[[personas]]
name = "sales-ops"
title = "Sales Operations"
description = "Manage sales workflows — track deals, schedule calls, client comms."
services = [ "gmail", "calendar", "sheets", "drive" ]
workflows = [ "+meeting-prep", "+email-to-task", "+weekly-digest" ]
instructions = [
"Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda.",
"Log deal updates in a tracking spreadsheet with `gws sheets +append`.",
"Convert follow-up emails into tasks with `gws workflow +email-to-task`.",
"Share proposals by uploading to Drive with `gws drive +upload`.",
"Get a weekly sales pipeline summary with `gws workflow +weekly-digest`."
]
tips = [
"Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails.",
"Schedule follow-up calls immediately after meetings to maintain momentum.",
"Keep all client-facing documents in a dedicated shared Drive folder."
]
[[personas]]
name = "it-admin"
title = "IT Administrator"
description = "Administer IT — monitor security and configure Workspace."
services = [ "gmail", "drive", "calendar" ]
workflows = [ "+standup-report" ]
instructions = [
"Start the day with `gws workflow +standup-report` to review any pending IT requests.",
"Monitor suspicious login activity and review audit logs.",
"Configure Drive sharing policies to enforce organizational security."
]
tips = [
"Always use `--dry-run` before bulk operations.",
"Review `gws auth status` regularly to verify service account permissions."
]
[[personas]]
name = "content-creator"
title = "Content Creator"
description = "Create, organize, and distribute content across Workspace."
services = [ "docs", "drive", "gmail", "chat", "slides" ]
workflows = [ "+file-announce" ]
instructions = [
"Draft content in Google Docs with `gws docs +write`.",
"Organize content assets in Drive folders — use `gws drive files list` to browse.",
"Share finished content by announcing in Chat with `gws workflow +file-announce`.",
"Send content review requests via email with `gws gmail +send`.",
"Upload media assets to Drive with `gws drive +upload`."
]
tips = [
"Use `gws docs +write` for quick content updates — it handles the Docs API formatting.",
"Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules.",
"Use `--format yaml` for human-readable output when debugging API responses."
]
[[personas]]
name = "customer-support"
title = "Customer Support Agent"
description = "Manage customer support — track tickets, respond, escalate issues."
services = [ "gmail", "sheets", "chat", "calendar" ]
workflows = [ "+email-to-task", "+standup-report" ]
instructions = [
"Triage the support inbox with `gws gmail +triage --query 'label:support'`.",
"Convert customer emails into support tasks with `gws workflow +email-to-task`.",
"Log ticket status updates in a tracking sheet with `gws sheets +append`.",
"Escalate urgent issues to the team Chat space.",
"Schedule follow-up calls with customers using `gws calendar +insert`."
]
tips = [
"Use `gws gmail +triage --labels` to see email categories at a glance.",
"Set up Gmail filters for auto-labeling support requests.",
"Use `--format table` for quick status dashboard views."
]
[[personas]]
name = "event-coordinator"
title = "Event Coordinator"
description = "Plan and manage events — scheduling, invitations, and logistics."
services = [ "calendar", "gmail", "drive", "chat", "sheets" ]
workflows = [ "+meeting-prep", "+file-announce", "+weekly-digest" ]
instructions = [
"Create event calendar entries with `gws calendar +insert` — include location and attendee lists.",
"Prepare event materials and upload to Drive with `gws drive +upload`.",
"Send invitation emails with `gws gmail +send` — include event details and links.",
"Announce updates in Chat spaces with `gws workflow +file-announce`.",
"Track RSVPs and logistics in Sheets with `gws sheets +append`."
]
tips = [
"Use `gws calendar +agenda --days 30` for long-range event planning.",
"Create a dedicated calendar for each major event series.",
"Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites."
]
[[personas]]
name = "team-lead"
title = "Team Lead"
description = "Lead a team — run standups, coordinate tasks, and communicate."
services = [ "calendar", "gmail", "chat", "drive", "sheets" ]
workflows = [
"+standup-report",
"+meeting-prep",
"+weekly-digest",
"+email-to-task"
]
instructions = [
"Run daily standups with `gws workflow +standup-report` — share output in team Chat.",
"Prepare for 1:1s with `gws workflow +meeting-prep`.",
"Get weekly snapshots with `gws workflow +weekly-digest`.",
"Delegate email action items with `gws workflow +email-to-task`.",
"Track team OKRs in a shared Sheet with `gws sheets +append`."
]
tips = [
"Use `gws calendar +agenda --week --format table` for weekly team calendar views.",
"Pipe standup reports to Chat with `gws chat spaces messages create`.",
"Use `--sanitize` for any operations involving sensitive team data."
]
[[personas]]
name = "researcher"
title = "Researcher"
description = "Organize research — manage references, notes, and collaboration."
services = [ "drive", "docs", "sheets", "gmail" ]
workflows = [ "+file-announce" ]
instructions = [
"Organize research papers and notes in Drive folders.",
"Write research notes and summaries with `gws docs +write`.",
"Track research data in Sheets — use `gws sheets +append` for data logging.",
"Share findings with collaborators via `gws workflow +file-announce`.",
"Request peer reviews via `gws gmail +send`."
]
tips = [
"Use `gws drive files list` with search queries to find specific documents.",
"Keep a running log of experiments and findings in a shared Sheet.",
"Use `--format csv` when exporting data for analysis tools."
]
@@ -0,0 +1,496 @@
[[recipes]]
name = "label-and-archive-emails"
title = "Label and Archive Gmail Threads"
description = "Apply Gmail labels to matching messages and archive them to keep your inbox clean."
category = "productivity"
services = [ "gmail" ]
steps = [
"Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`",
"Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`",
"Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`"
]
[[recipes]]
name = "draft-email-from-doc"
title = "Draft a Gmail Message from a Google Doc"
description = "Read content from a Google Doc and use it as the body of a Gmail message."
category = "productivity"
services = [ "docs", "gmail" ]
steps = [
"Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`",
"Copy the text from the body content",
"Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`"
]
[[recipes]]
name = "organize-drive-folder"
title = "Organize Files into Google Drive Folders"
description = "Create a Google Drive folder structure and move files into the right locations."
category = "productivity"
services = [ "drive" ]
steps = [
"Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`",
"Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`",
"Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`",
"Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`"
]
[[recipes]]
name = "share-folder-with-team"
title = "Share a Google Drive Folder with a Team"
description = "Share a Google Drive folder and all its contents with a list of collaborators."
category = "productivity"
services = [ "drive" ]
steps = [
"Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`",
"Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`",
"Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`",
"Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`"
]
[[recipes]]
name = "email-drive-link"
title = "Email a Google Drive File Link"
description = "Share a Google Drive file and email the link with a message to recipients."
category = "productivity"
services = [ "drive", "gmail" ]
steps = [
"Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`",
"Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`",
"Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`"
]
[[recipes]]
name = "create-doc-from-template"
title = "Create a Google Doc from a Template"
description = "Copy a Google Docs template, fill in content, and share with collaborators."
category = "productivity"
services = [ "drive", "docs" ]
steps = [
"Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`",
"Get the new doc ID from the response",
"Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`",
"Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`"
]
[[recipes]]
name = "create-expense-tracker"
title = "Create a Google Sheets Expense Tracker"
description = "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries."
category = "productivity"
services = [ "sheets", "drive" ]
steps = [
"Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`",
"Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`",
"Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`",
"Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`"
]
[[recipes]]
name = "copy-sheet-for-new-month"
title = "Copy a Google Sheet for a New Month"
description = "Duplicate a Google Sheets template tab for a new month of tracking."
category = "productivity"
services = [ "sheets" ]
steps = [
"Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`",
"Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`",
"Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`"
]
[[recipes]]
name = "block-focus-time"
title = "Block Focus Time on Google Calendar"
description = "Create recurring focus time blocks on Google Calendar to protect deep work hours."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`",
"Verify it shows as busy: `gws calendar +agenda`"
]
[[recipes]]
name = "reschedule-meeting"
title = "Reschedule a Google Calendar Meeting"
description = "Move a Google Calendar event to a new time and automatically notify all attendees."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Find the event: `gws calendar +agenda`",
"Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
"Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`"
]
[[recipes]]
name = "create-gmail-filter"
title = "Create a Gmail Filter"
description = "Create a Gmail filter to automatically label, star, or categorize incoming messages."
category = "productivity"
services = [ "gmail" ]
steps = [
"List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`",
"Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`",
"Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`",
"Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`"
]
[[recipes]]
name = "schedule-recurring-event"
title = "Schedule a Recurring Meeting"
description = "Create a recurring Google Calendar event with attendees."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`",
"Verify it was created: `gws calendar +agenda --days 14 --format table`"
]
[[recipes]]
name = "find-free-time"
title = "Find Free Time Across Calendars"
description = "Query Google Calendar free/busy status for multiple users to find a meeting slot."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`",
"Review the output to find overlapping free slots",
"Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`"
]
[[recipes]]
name = "bulk-download-folder"
title = "Bulk Download Drive Folder"
description = "List and download all files from a Google Drive folder."
category = "productivity"
services = [ "drive" ]
steps = [
"List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`",
"Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`",
"Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`"
]
[[recipes]]
name = "find-large-files"
title = "Find Largest Files in Drive"
description = "Identify large Google Drive files consuming storage quota."
category = "productivity"
services = [ "drive" ]
steps = [
"List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`",
"Review the output and identify files to archive or move"
]
[[recipes]]
name = "create-shared-drive"
title = "Create and Configure a Shared Drive"
description = "Create a Google Shared Drive and add members with appropriate roles."
category = "productivity"
services = [ "drive" ]
steps = [
"Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`",
"Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`",
"List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`"
]
[[recipes]]
name = "log-deal-update"
title = "Log Deal Update to Sheet"
description = "Append a deal status update to a Google Sheets sales tracking spreadsheet."
category = "sales"
services = [ "sheets", "drive" ]
steps = [
"Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`",
"Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`",
"Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"$50,000\", \"Q2\", \"jdoe\"]'`"
]
[[recipes]]
name = "collect-form-responses"
title = "Check Form Responses"
description = "Retrieve and review responses from a Google Form."
category = "productivity"
services = [ "forms" ]
steps = [
"List forms: `gws forms forms list` (if you don't have the form ID)",
"Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`",
"Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`"
]
[[recipes]]
name = "post-mortem-setup"
title = "Set Up Post-Mortem"
description = "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat."
category = "engineering"
services = [ "docs", "calendar", "chat" ]
steps = [
"Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`",
"Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`",
"Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`"
]
[[recipes]]
name = "create-task-list"
title = "Create a Task List and Add Tasks"
description = "Set up a new Google Tasks list with initial tasks."
category = "productivity"
services = [ "tasks" ]
steps = [
"Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`",
"Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`",
"Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`",
"List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`"
]
[[recipes]]
name = "review-overdue-tasks"
title = "Review Overdue Tasks"
description = "Find Google Tasks that are past due and need attention."
category = "productivity"
services = [ "tasks" ]
steps = [
"List task lists: `gws tasks tasklists list --format table`",
"List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`",
"Review due dates and prioritize overdue items"
]
[[recipes]]
name = "watch-drive-changes"
title = "Watch for Drive Changes"
description = "Subscribe to change notifications on a Google Drive file or folder."
category = "engineering"
services = [ "events" ]
steps = [
"Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`",
"List active subscriptions: `gws events subscriptions list`",
"Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`"
]
[[recipes]]
name = "create-classroom-course"
title = "Create a Google Classroom Course"
description = "Create a Google Classroom course and invite students."
category = "education"
services = [ "classroom" ]
steps = [
"Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`",
"Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`",
"List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`"
]
[[recipes]]
name = "create-meet-space"
title = "Create a Google Meet Conference"
description = "Create a Google Meet meeting space and share the join link."
category = "scheduling"
services = [ "meet", "gmail" ]
steps = [
"Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`",
"Copy the meeting URI from the response",
"Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`"
]
[[recipes]]
name = "review-meet-participants"
title = "Review Google Meet Attendance"
description = "Review who attended a Google Meet conference and for how long."
category = "productivity"
services = [ "meet" ]
steps = [
"List recent conferences: `gws meet conferenceRecords list --format table`",
"List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`",
"Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`"
]
[[recipes]]
name = "create-presentation"
title = "Create a Google Slides Presentation"
description = "Create a new Google Slides presentation and add initial slides."
category = "productivity"
services = [ "slides" ]
steps = [
"Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`",
"Get the presentation ID from the response",
"Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`"
]
[[recipes]]
name = "save-email-attachments"
title = "Save Gmail Attachments to Google Drive"
description = "Find Gmail messages with attachments and save them to a Google Drive folder."
category = "productivity"
services = [ "gmail", "drive" ]
steps = [
"Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`",
"Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`",
"Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`",
"Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`"
]
[[recipes]]
name = "send-team-announcement"
title = "Announce via Gmail and Google Chat"
description = "Send a team announcement via both Gmail and a Google Chat space."
category = "communication"
services = [ "gmail", "chat" ]
steps = [
"Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`",
"Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`"
]
[[recipes]]
name = "create-feedback-form"
title = "Create and Share a Google Form"
description = "Create a Google Form for feedback and share it via Gmail."
category = "productivity"
services = [ "forms", "gmail" ]
steps = [
"Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`",
"Get the form URL from the response (responderUri field)",
"Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`"
]
[[recipes]]
name = "sync-contacts-to-sheet"
title = "Export Google Contacts to Sheets"
description = "Export Google Contacts directory to a Google Sheets spreadsheet."
category = "productivity"
services = [ "people", "sheets" ]
steps = [
"List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`",
"Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`",
"Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`"
]
[[recipes]]
name = "share-event-materials"
title = "Share Files with Meeting Attendees"
description = "Share Google Drive files with all attendees of a Google Calendar event."
category = "productivity"
services = [ "calendar", "drive" ]
steps = [
"Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
"Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`",
"Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`"
]
[[recipes]]
name = "create-vacation-responder"
title = "Set Up a Gmail Vacation Responder"
description = "Enable a Gmail out-of-office auto-reply with a custom message and date range."
category = "productivity"
services = [ "gmail" ]
steps = [
"Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`",
"Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`",
"Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`"
]
[[recipes]]
name = "create-events-from-sheet"
title = "Create Google Calendar Events from a Sheet"
description = "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row."
category = "productivity"
services = [ "sheets", "calendar" ]
steps = [
"Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`",
"For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`"
]
[[recipes]]
name = "plan-weekly-schedule"
title = "Plan Your Weekly Google Calendar Schedule"
description = "Review your Google Calendar week, identify gaps, and add events to fill them."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Check this week's agenda: `gws calendar +agenda`",
"Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`",
"Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`",
"Review updated schedule: `gws calendar +agenda`"
]
[[recipes]]
name = "share-doc-and-notify"
title = "Share a Google Doc and Notify Collaborators"
description = "Share a Google Docs document with edit access and email collaborators the link."
category = "productivity"
services = [ "drive", "docs", "gmail" ]
steps = [
"Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`",
"Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`",
"Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`"
]
[[recipes]]
name = "backup-sheet-as-csv"
title = "Export a Google Sheet as CSV"
description = "Export a Google Sheets spreadsheet as a CSV file for local backup or processing."
category = "productivity"
services = [ "sheets", "drive" ]
steps = [
"Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`",
"Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`",
"Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`"
]
[[recipes]]
name = "save-email-to-doc"
title = "Save a Gmail Message to Google Docs"
description = "Save a Gmail message body into a Google Doc for archival or reference."
category = "productivity"
services = [ "gmail", "docs" ]
steps = [
"Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`",
"Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`",
"Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`",
"Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`"
]
[[recipes]]
name = "compare-sheet-tabs"
title = "Compare Two Google Sheets Tabs"
description = "Read data from two tabs in a Google Sheet to compare and identify differences."
category = "productivity"
services = [ "sheets" ]
steps = [
"Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`",
"Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`",
"Compare the data and identify changes"
]
[[recipes]]
name = "batch-invite-to-event"
title = "Add Multiple Attendees to a Calendar Event"
description = "Add a list of attendees to an existing Google Calendar event and send notifications."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
"Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`",
"Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`"
]
[[recipes]]
name = "forward-labeled-emails"
title = "Forward Labeled Gmail Messages"
description = "Find Gmail messages with a specific label and forward them to another address."
category = "productivity"
services = [ "gmail" ]
steps = [
"Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`",
"Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`",
"Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`"
]
[[recipes]]
name = "generate-report-from-sheet"
title = "Generate a Google Docs Report from Sheet Data"
description = "Read data from a Google Sheet and create a formatted Google Docs report."
category = "productivity"
services = [ "sheets", "docs", "drive" ]
steps = [
"Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`",
"Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`",
"Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: $125,000\n\n### Top Deals\n1. Acme Corp - $25,000\n2. Widget Inc - $18,000'`",
"Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`"
]
+995
View File
@@ -0,0 +1,995 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Authentication and Credential Management
//!
//! Handles obtaining OAuth 2.0 access tokens and Service Account tokens.
//! Supports local user flow (via a loopback server) and Application Default Credentials,
//! with token caching to minimize repeated authentication overhead.
use std::path::PathBuf;
use anyhow::Context;
use serde::Deserialize;
use crate::credential_store;
const PROXY_ENV_VARS: &[&str] = &[
"http_proxy",
"HTTP_PROXY",
"https_proxy",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
];
/// Response from Google's token endpoint
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[allow(dead_code)]
expires_in: u64,
#[allow(dead_code)]
token_type: String,
}
/// Refresh an access token using reqwest (supports HTTP proxy via environment variables).
/// This is used as a fallback when yup-oauth2's hyper-based client fails due to proxy issues.
async fn refresh_token_with_reqwest(
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> anyhow::Result<String> {
let client = crate::client::shared_client().map_err(anyhow::Error::from)?;
let params = [
("client_id", client_id),
("client_secret", client_secret),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
];
let response = client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
.context("Failed to send token refresh request")?;
if !response.status().is_success() {
let status = response.status();
let body = response_text_or_placeholder(response.text().await);
anyhow::bail!("Token refresh failed with status {}: {}", status, body);
}
let token_response: TokenResponse = response
.json()
.await
.context("Failed to parse token response")?;
Ok(token_response.access_token)
}
/// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header).
///
/// Priority:
/// 1. `GOOGLE_WORKSPACE_PROJECT_ID` environment variable.
/// 2. `project_id` from the OAuth client configuration (`client_secret.json`).
/// 3. `quota_project_id` from Application Default Credentials (ADC).
pub fn get_quota_project() -> Option<String> {
// 1. Explicit environment variable (highest priority)
if let Ok(project_id) = std::env::var("GOOGLE_WORKSPACE_PROJECT_ID") {
if !project_id.is_empty() {
return Some(project_id);
}
}
// 2. Project ID from the OAuth client configuration (set via `gws auth setup`)
if let Ok(config) = crate::oauth_config::load_client_config() {
if !config.project_id.is_empty() {
return Some(config.project_id);
}
}
// 3. Fallback to Application Default Credentials (ADC)
let path = std::env::var("GOOGLE_APPLICATION_CREDENTIALS")
.ok()
.map(PathBuf::from)
.or_else(adc_well_known_path)?;
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
json.get("quota_project_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Returns the well-known Application Default Credentials path:
/// `~/.config/gcloud/application_default_credentials.json`.
///
/// Note: `dirs::config_dir()` returns `~/Library/Application Support` on macOS, which is
/// wrong for gcloud. The Google Cloud SDK always uses `~/.config/gcloud` regardless of OS.
fn adc_well_known_path() -> Option<PathBuf> {
dirs::home_dir().map(|d| {
d.join(".config")
.join("gcloud")
.join("application_default_credentials.json")
})
}
/// Types of credentials we support
#[derive(Debug)]
enum Credential {
AuthorizedUser(yup_oauth2::authorized_user::AuthorizedUserSecret),
ServiceAccount(yup_oauth2::ServiceAccountKey),
}
/// Fetches access tokens for a fixed set of scopes.
///
/// Long-running helpers use this trait so they can request a fresh token before
/// each API call instead of holding a single token string until it expires.
#[async_trait::async_trait]
pub trait AccessTokenProvider: Send + Sync {
async fn access_token(&self) -> anyhow::Result<String>;
}
/// A token provider backed by [`get_token`].
///
/// This keeps the scope list in one place so call sites can ask for a fresh
/// token whenever they need to make another request.
#[derive(Debug, Clone)]
pub struct ScopedTokenProvider {
scopes: Vec<String>,
}
impl ScopedTokenProvider {
pub fn new(scopes: &[&str]) -> Self {
Self {
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
}
}
}
#[async_trait::async_trait]
impl AccessTokenProvider for ScopedTokenProvider {
async fn access_token(&self) -> anyhow::Result<String> {
let scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect();
get_token(&scopes).await
}
}
pub fn token_provider(scopes: &[&str]) -> ScopedTokenProvider {
ScopedTokenProvider::new(scopes)
}
/// A fake [`AccessTokenProvider`] for tests that returns tokens from a queue.
#[cfg(test)]
pub struct FakeTokenProvider {
tokens: std::sync::Arc<tokio::sync::Mutex<std::collections::VecDeque<String>>>,
}
#[cfg(test)]
impl FakeTokenProvider {
pub fn new(tokens: impl IntoIterator<Item = &'static str>) -> Self {
Self {
tokens: std::sync::Arc::new(tokio::sync::Mutex::new(
tokens.into_iter().map(|t| t.to_string()).collect(),
)),
}
}
}
#[cfg(test)]
#[async_trait::async_trait]
impl AccessTokenProvider for FakeTokenProvider {
async fn access_token(&self) -> anyhow::Result<String> {
self.tokens
.lock()
.await
.pop_front()
.ok_or_else(|| anyhow::anyhow!("no test token remaining"))
}
}
/// Builds an OAuth2 authenticator and returns an access token.
///
/// Tries credentials in order:
/// 0. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
/// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account)
/// 2. Encrypted credentials at `~/.config/gws/credentials.enc`
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
/// 4. Application Default Credentials (ADC):
/// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
/// (populated by `gcloud auth application-default login`)
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
// 0. Direct token from env var (highest priority, bypasses all credential loading)
if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") {
if !token.is_empty() {
return Ok(token);
}
}
let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
let config_dir = crate::auth_commands::config_dir();
let enc_path = credential_store::encrypted_credentials_path();
let default_path = config_dir.join("credentials.json");
let token_cache = config_dir.join("token_cache.json");
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
get_token_inner(scopes, creds, &token_cache).await
}
/// Check if HTTP proxy environment variables are set
pub(crate) fn has_proxy_env() -> bool {
PROXY_ENV_VARS
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
}
pub(crate) fn response_text_or_placeholder<E>(result: Result<String, E>) -> String {
result.unwrap_or_else(|_| "(could not read error response body)".to_string())
}
async fn get_token_inner(
scopes: &[&str],
creds: Credential,
token_cache_path: &std::path::Path,
) -> anyhow::Result<String> {
match creds {
Credential::AuthorizedUser(ref secret) => {
// If proxy env vars are set, use reqwest directly (it supports proxy)
// This avoids waiting for yup-oauth2's hyper client to timeout
if has_proxy_env() {
return refresh_token_with_reqwest(
&secret.client_id,
&secret.client_secret,
&secret.refresh_token,
)
.await;
}
// No proxy - use yup-oauth2 (faster, has token caching)
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret.clone())
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
token_cache_path.to_path_buf(),
)))
.build()
.await
.context("Failed to build authorized user authenticator")?;
let token = auth.token(scopes).await.context("Failed to get token")?;
Ok(token
.token()
.ok_or_else(|| anyhow::anyhow!("Token response contained no access token"))?
.to_string())
}
Credential::ServiceAccount(key) => {
let tc_filename = token_cache_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "token_cache.json".to_string());
let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}"));
let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(
Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)),
);
let auth = builder
.build()
.await
.context("Failed to build service account authenticator")?;
let token = auth.token(scopes).await.context("Failed to get token")?;
Ok(token
.token()
.ok_or_else(|| anyhow::anyhow!("Token response contained no access token"))?
.to_string())
}
}
}
/// Parse a plaintext JSON credential file into a [`Credential`].
///
/// Determines the credential type from the `"type"` field:
/// - `"service_account"` → [`Credential::ServiceAccount`]
/// - anything else (including `"authorized_user"`) → [`Credential::AuthorizedUser`]
///
/// Uses the already-parsed `serde_json::Value` to avoid a second string parse.
async fn parse_credential_file(
path: &std::path::Path,
content: &str,
) -> anyhow::Result<Credential> {
let json: serde_json::Value = serde_json::from_str(content)
.with_context(|| format!("Failed to parse credentials JSON at {}", path.display()))?;
if json.get("type").and_then(|v| v.as_str()) == Some("service_account") {
let key = yup_oauth2::parse_service_account_key(content).with_context(|| {
format!(
"Failed to parse service account key from {}",
path.display()
)
})?;
return Ok(Credential::ServiceAccount(key));
}
// Deserialize from the Value we already have — avoids a second string parse.
let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = serde_json::from_value(json)
.with_context(|| {
format!(
"Failed to parse authorized user credentials from {}",
path.display()
)
})?;
Ok(Credential::AuthorizedUser(secret))
}
async fn load_credentials_inner(
env_file: Option<&str>,
enc_path: &std::path::Path,
default_path: &std::path::Path,
) -> anyhow::Result<Credential> {
// 1. Explicit env var — plaintext file (User or Service Account)
if let Some(path) = env_file {
let p = PathBuf::from(path);
if p.exists() {
let content = tokio::fs::read_to_string(&p)
.await
.with_context(|| format!("Failed to read credentials from {path}"))?;
return parse_credential_file(&p, &content).await;
}
anyhow::bail!(
"GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist"
);
}
// 2. Encrypted credentials
if enc_path.exists() {
match credential_store::load_encrypted_from_path(enc_path) {
Ok(json_str) => {
return parse_credential_file(enc_path, &json_str).await;
}
Err(e) => {
// Decryption failed — the encryption key likely changed (e.g. after
// an upgrade that migrated keys between keyring and file storage).
// Remove the stale file so the next `gws auth login` starts fresh,
// and fall through to other credential sources (plaintext, ADC).
eprintln!(
"Warning: removing undecryptable credentials file ({}): {e:#}",
enc_path.display()
);
if let Err(err) = tokio::fs::remove_file(enc_path).await {
eprintln!(
"Warning: failed to remove stale credentials file '{}': {err}",
enc_path.display()
);
}
// Also remove stale token caches that used the old key.
for cache_file in ["token_cache.json", "sa_token_cache.json"] {
let path = enc_path.with_file_name(cache_file);
if let Err(err) = tokio::fs::remove_file(&path).await {
if err.kind() != std::io::ErrorKind::NotFound {
eprintln!(
"Warning: failed to remove stale token cache '{}': {err}",
path.display()
);
}
}
}
// Fall through to remaining credential sources below.
}
}
}
// 3. Plaintext credentials at default path (AuthorizedUser)
if default_path.exists() {
return Ok(Credential::AuthorizedUser(
yup_oauth2::read_authorized_user_secret(default_path)
.await
.with_context(|| {
format!("Failed to read credentials from {}", default_path.display())
})?,
));
}
// 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing)
if let Ok(adc_env) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
let adc_path = PathBuf::from(&adc_env);
if adc_path.exists() {
let content = tokio::fs::read_to_string(&adc_path)
.await
.with_context(|| format!("Failed to read ADC from {adc_env}"))?;
return parse_credential_file(&adc_path, &content).await;
}
anyhow::bail!(
"GOOGLE_APPLICATION_CREDENTIALS points to {adc_env}, but file does not exist"
);
}
// 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json
// (populated by `gcloud auth application-default login`). Silent if absent.
if let Some(well_known) = adc_well_known_path() {
if well_known.exists() {
let content = tokio::fs::read_to_string(&well_known)
.await
.with_context(|| format!("Failed to read ADC from {}", well_known.display()))?;
return parse_credential_file(&well_known, &content).await;
}
}
anyhow::bail!(
"No credentials found. Run `gws auth setup` to configure, \
`gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE.\n\
Tip: Application Default Credentials (ADC) are also supported — run \
`gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS."
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
/// RAII guard that saves the current value of an environment variable and
/// restores it when dropped. This ensures cleanup even if a test panics.
struct EnvVarGuard {
name: String,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
/// Save the current value of `name`, then set it to `value`.
fn set(name: &str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let original = std::env::var_os(name);
std::env::set_var(name, value);
Self {
name: name.to_string(),
original,
}
}
/// Save the current value of `name`, then remove it.
fn remove(name: &str) -> Self {
let original = std::env::var_os(name);
std::env::remove_var(name);
Self {
name: name.to_string(),
original,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var(&self.name, v),
None => std::env::remove_var(&self.name),
}
}
}
fn clear_proxy_env() -> Vec<EnvVarGuard> {
PROXY_ENV_VARS
.iter()
.map(|key| EnvVarGuard::remove(key))
.collect()
}
#[test]
#[serial_test::serial]
fn has_proxy_env_returns_false_when_unset() {
let _guards = clear_proxy_env();
assert!(!has_proxy_env());
}
#[test]
#[serial_test::serial]
fn has_proxy_env_returns_true_when_set() {
let mut guards = clear_proxy_env();
guards.push(EnvVarGuard::set(
"HTTPS_PROXY",
"http://proxy.internal:8080",
));
assert!(has_proxy_env());
}
#[test]
fn response_text_or_placeholder_returns_body() {
let body = response_text_or_placeholder(Result::<String, ()>::Ok("error body".to_string()));
assert_eq!(body, "error body");
}
#[test]
fn response_text_or_placeholder_returns_placeholder_on_error() {
let body = response_text_or_placeholder(Result::<String, ()>::Err(()));
assert_eq!(body, "(could not read error response body)");
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_no_options() {
// Isolate from host ADC: override HOME so adc_well_known_path()
// resolves to a non-existent directory, and clear the env var.
let tmp = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let err = load_credentials_inner(
None,
&PathBuf::from("/does/not/exist1"),
&PathBuf::from("/does/not/exist2"),
)
.await;
assert!(err.is_err());
assert!(err
.unwrap_err()
.to_string()
.contains("No credentials found"));
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_adc_env_var_authorized_user() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"client_id": "adc_id",
"client_secret": "adc_secret",
"refresh_token": "adc_refresh",
"type": "authorized_user"
}"#;
file.write_all(json.as_bytes()).unwrap();
let _adc_guard = EnvVarGuard::set(
"GOOGLE_APPLICATION_CREDENTIALS",
file.path().to_str().unwrap(),
);
let res = load_credentials_inner(
None,
&PathBuf::from("/missing/enc"),
&PathBuf::from("/missing/plain"),
)
.await;
match res.unwrap() {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "adc_id");
assert_eq!(secret.refresh_token, "adc_refresh");
}
_ => panic!("Expected AuthorizedUser from ADC"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_adc_env_var_service_account() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"type": "service_account",
"project_id": "test-project",
"private_key_id": "adc-key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
"client_email": "adc-sa@test-project.iam.gserviceaccount.com",
"client_id": "456",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}"#;
file.write_all(json.as_bytes()).unwrap();
let _adc_guard = EnvVarGuard::set(
"GOOGLE_APPLICATION_CREDENTIALS",
file.path().to_str().unwrap(),
);
let res = load_credentials_inner(
None,
&PathBuf::from("/missing/enc"),
&PathBuf::from("/missing/plain"),
)
.await;
match res.unwrap() {
Credential::ServiceAccount(key) => {
assert_eq!(
key.client_email,
"adc-sa@test-project.iam.gserviceaccount.com"
);
}
_ => panic!("Expected ServiceAccount from ADC"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_adc_env_var_missing_file() {
let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json");
// When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately
// rather than falling through — the user explicitly asked for this file.
let err = load_credentials_inner(
None,
&PathBuf::from("/missing/enc"),
&PathBuf::from("/missing/plain"),
)
.await;
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("does not exist"),
"Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}"
);
}
#[tokio::test]
async fn test_load_credentials_env_file_missing() {
let err = load_credentials_inner(
Some("/does/not/exist"),
&PathBuf::from("/also/missing"),
&PathBuf::from("/still/missing"),
)
.await;
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("does not exist"));
}
#[tokio::test]
async fn test_load_credentials_env_file_authorized_user() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"client_id": "test_id",
"client_secret": "test_secret",
"refresh_token": "test_refresh",
"type": "authorized_user"
}"#;
file.write_all(json.as_bytes()).unwrap();
let res = load_credentials_inner(
Some(file.path().to_str().unwrap()),
&PathBuf::from("/also/missing"),
&PathBuf::from("/still/missing"),
)
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "test_id");
assert_eq!(secret.refresh_token, "test_refresh");
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[tokio::test]
async fn test_load_credentials_env_file_service_account() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"type": "service_account",
"project_id": "test",
"private_key_id": "test-key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
"client_email": "test@test.iam.gserviceaccount.com",
"client_id": "123",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}"#;
file.write_all(json.as_bytes()).unwrap();
let res = load_credentials_inner(
Some(file.path().to_str().unwrap()),
&PathBuf::from("/also/missing"),
&PathBuf::from("/still/missing"),
)
.await
.unwrap();
match res {
Credential::ServiceAccount(key) => {
assert_eq!(key.client_email, "test@test.iam.gserviceaccount.com");
}
_ => panic!("Expected ServiceAccount"),
}
}
#[tokio::test]
async fn test_load_credentials_default_path_authorized_user() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"client_id": "default_id",
"client_secret": "default_secret",
"refresh_token": "default_refresh",
"type": "authorized_user"
}"#;
file.write_all(json.as_bytes()).unwrap();
let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path())
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "default_id");
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_get_token_from_env_var() {
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
let result = get_token(&["https://www.googleapis.com/auth/drive"]).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "my-test-token");
}
#[tokio::test]
#[serial_test::serial]
async fn test_scoped_token_provider_uses_get_token() {
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "provider-token");
let provider = token_provider(&["https://www.googleapis.com/auth/drive"]);
let first = provider.access_token().await.unwrap();
let second = provider.access_token().await.unwrap();
assert_eq!(first, "provider-token");
assert_eq!(second, "provider-token");
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_encrypted_file() {
// Simulate an encrypted credentials file
let json = r#"{
"client_id": "enc_test_id",
"client_secret": "enc_test_secret",
"refresh_token": "enc_test_refresh",
"type": "authorized_user"
}"#;
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
// Isolate global config dir to prevent races with other tests
std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path());
// Encrypt and write
let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap();
std::fs::write(&enc_path, &encrypted).unwrap();
let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist"))
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "enc_test_id");
assert_eq!(secret.client_secret, "enc_test_secret");
assert_eq!(secret.refresh_token, "enc_test_refresh");
}
_ => panic!("Expected AuthorizedUser from encrypted credentials"),
}
}
#[tokio::test]
async fn test_load_credentials_encrypted_takes_priority_over_default() {
// Encrypted credentials should be loaded before the default plaintext path
let enc_json = r#"{
"client_id": "encrypted_id",
"client_secret": "encrypted_secret",
"refresh_token": "encrypted_refresh",
"type": "authorized_user"
}"#;
let plain_json = r#"{
"client_id": "plaintext_id",
"client_secret": "plaintext_secret",
"refresh_token": "plaintext_refresh",
"type": "authorized_user"
}"#;
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
let plain_path = dir.path().join("credentials.json");
let encrypted = crate::credential_store::encrypt(enc_json.as_bytes()).unwrap();
std::fs::write(&enc_path, &encrypted).unwrap();
std::fs::write(&plain_path, plain_json).unwrap();
let res = load_credentials_inner(None, &enc_path, &plain_path)
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(
secret.client_id, "encrypted_id",
"Encrypted credentials should take priority over plaintext"
);
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_corrupt_encrypted_file_is_removed() {
// When credentials.enc cannot be decrypted, the file should be removed
// automatically and the function should fall through to other sources.
let tmp = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
// Write garbage data that cannot be decrypted.
tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890")
.await
.unwrap();
assert!(enc_path.exists());
let result =
load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await;
// Should fall through to "No credentials found" (not a decryption error).
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("No credentials found"),
"Should fall through to final error, got: {msg}"
);
assert!(
!enc_path.exists(),
"Stale credentials.enc must be removed after decryption failure"
);
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_corrupt_encrypted_falls_through_to_plaintext() {
// When credentials.enc is corrupt but a valid plaintext file exists,
// the function should fall through and use the plaintext credentials.
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
let plain_path = dir.path().join("credentials.json");
// Write garbage encrypted data.
tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890")
.await
.unwrap();
// Write valid plaintext credentials.
let plain_json = r#"{
"client_id": "fallback_id",
"client_secret": "fallback_secret",
"refresh_token": "fallback_refresh",
"type": "authorized_user"
}"#;
tokio::fs::write(&plain_path, plain_json).await.unwrap();
let res = load_credentials_inner(None, &enc_path, &plain_path)
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(
secret.client_id, "fallback_id",
"Should fall through to plaintext credentials"
);
}
_ => panic!("Expected AuthorizedUser from plaintext fallback"),
}
assert!(!enc_path.exists(), "Stale credentials.enc must be removed");
}
#[tokio::test]
#[serial_test::serial]
async fn test_get_token_env_var_empty_falls_through() {
// An empty token should not short-circuit — it should be ignored
// and fall through to normal credential loading.
// Isolate from host ADC so the well-known path doesn't match.
let tmp = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "");
let result = load_credentials_inner(
None,
&PathBuf::from("/does/not/exist1"),
&PathBuf::from("/does/not/exist2"),
)
.await;
// Should fall through to normal credential loading, which fails
// because we pointed at non-existent paths
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No credentials found"));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_priority_env_var() {
let _env_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_PROJECT_ID", "priority-env");
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
let _home_guard = EnvVarGuard::set("HOME", "/missing/home");
assert_eq!(get_quota_project(), Some("priority-env".to_string()));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_priority_config() {
let tmp = tempfile::tempdir().unwrap();
let _config_guard = EnvVarGuard::set(
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR",
tmp.path().to_str().unwrap(),
);
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let _home_guard = EnvVarGuard::set("HOME", "/missing/home");
// Save a client config with a project ID
crate::oauth_config::save_client_config("id", "secret", "config-project").unwrap();
assert_eq!(get_quota_project(), Some("config-project".to_string()));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_priority_adc_fallback() {
let tmp = tempfile::tempdir().unwrap();
let adc_dir = tmp.path().join(".config").join("gcloud");
std::fs::create_dir_all(&adc_dir).unwrap();
std::fs::write(
adc_dir.join("application_default_credentials.json"),
r#"{"quota_project_id": "adc-project"}"#,
)
.unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
assert_eq!(get_quota_project(), Some("adc-project".to_string()));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_reads_adc() {
let tmp = tempfile::tempdir().unwrap();
let adc_dir = tmp.path().join(".config").join("gcloud");
std::fs::create_dir_all(&adc_dir).unwrap();
std::fs::write(
adc_dir.join("application_default_credentials.json"),
r#"{"quota_project_id": "my-project-123"}"#,
)
.unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
// Isolate from local environment
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
assert_eq!(get_quota_project(), Some("my-project-123".to_string()));
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! HTTP client — re-exports from `google_workspace` library crate.
pub use google_workspace::client::*;
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use clap::{Arg, Command};
use crate::discovery::{RestDescription, RestResource};
/// Builds the full CLI command tree from a Discovery Document.
pub fn build_cli(doc: &RestDescription) -> Command {
let about_text = doc
.description
.clone()
.unwrap_or_else(|| "Google Workspace CLI".to_string());
let mut root = Command::new("gws")
.about(about_text)
.subcommand_required(true)
.arg_required_else_help(true)
.arg(
clap::Arg::new("sanitize")
.long("sanitize")
.help("Sanitize API responses through a Model Armor template. Requires cloud-platform scope. Format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE. Also reads GWS_SANITIZE_TEMPLATE env var.")
.value_name("TEMPLATE")
.global(true),
)
.arg(
clap::Arg::new("dry-run")
.long("dry-run")
.help("Validate the request locally without sending it to the API")
.action(clap::ArgAction::SetTrue)
.global(true),
)
.arg(
clap::Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
);
// Inject helper commands
let helper = crate::helpers::get_helper(&doc.name);
if let Some(ref helper) = helper {
root = helper.inject_commands(root, doc);
}
// Add resource subcommands (unless helper suppresses them)
let skip_resources = helper.as_ref().is_some_and(|h| h.helper_only());
if !skip_resources {
let mut resource_names: Vec<_> = doc.resources.keys().collect();
resource_names.sort();
for name in resource_names {
let resource = &doc.resources[name];
if let Some(cmd) = build_resource_command(name, resource) {
root = root.subcommand(cmd);
}
}
}
root
}
/// Recursively builds a Command for a resource.
/// Returns None if the resource has no methods or sub-resources.
fn build_resource_command(name: &str, resource: &RestResource) -> Option<Command> {
let mut cmd = Command::new(name.to_string())
.about(format!("Operations on the '{name}' resource"))
.subcommand_required(true)
.arg_required_else_help(true);
let mut has_children = false;
// Add method subcommands
let mut method_names: Vec<_> = resource.methods.keys().collect();
method_names.sort();
for method_name in method_names {
let method = &resource.methods[method_name];
has_children = true;
let about = crate::text::truncate_description(
method.description.as_deref().unwrap_or(""),
crate::text::CLI_DESCRIPTION_LIMIT,
true,
);
let mut method_cmd = Command::new(method_name.to_string())
.about(about)
.arg(
Arg::new("params")
.long("params")
.help("JSON string for URL/Query parameters")
.value_name("JSON"),
)
.arg(
Arg::new("output")
.long("output")
.short('o')
.help("Output file path for binary responses")
.value_name("PATH"),
);
// Only add --json flag if the method accepts a request body
if method.request.is_some() {
method_cmd = method_cmd.arg(
Arg::new("json")
.long("json")
.help("JSON string for the request body")
.value_name("JSON"),
);
}
// Add --upload flag if the method supports media upload
if method.supports_media_upload {
method_cmd = method_cmd
.arg(
Arg::new("upload")
.long("upload")
.help("Local file path to upload as media content (multipart upload)")
.value_name("PATH"),
)
.arg(
Arg::new("upload-content-type")
.long("upload-content-type")
.help("MIME type of the uploaded file content (e.g. text/markdown). If omitted, detected from file extension or metadata mimeType")
.value_name("MIME"),
);
}
// Pagination flags
method_cmd = method_cmd
.arg(
Arg::new("page-all")
.long("page-all")
.help("Auto-paginate through all results, outputting one JSON line per page (NDJSON)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("page-limit")
.long("page-limit")
.help("Maximum number of pages to fetch when using --page-all (default: 10)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
.arg(
Arg::new("page-delay")
.long("page-delay")
.help("Delay in milliseconds between page fetches (default: 100)")
.value_name("MS")
.value_parser(clap::value_parser!(u64)),
);
cmd = cmd.subcommand(method_cmd);
}
// Add sub-resource subcommands (recursive)
let mut sub_names: Vec<_> = resource.resources.keys().collect();
sub_names.sort();
for sub_name in sub_names {
let sub_resource = &resource.resources[sub_name];
if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource) {
has_children = true;
cmd = cmd.subcommand(sub_cmd);
}
}
if has_children {
Some(cmd)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestMethod, RestResource};
use std::collections::HashMap;
fn make_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"list".to_string(),
RestMethod {
id: None,
description: None,
http_method: "GET".to_string(),
path: "list".to_string(),
parameters: HashMap::new(),
parameter_order: vec![],
request: None,
response: None,
scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()],
flat_path: None,
supports_media_download: false,
supports_media_upload: false,
media_upload: None,
},
);
methods.insert(
"delete".to_string(),
RestMethod {
id: None,
description: None,
http_method: "DELETE".to_string(),
path: "delete".to_string(),
parameters: HashMap::new(),
parameter_order: vec![],
request: None,
response: None,
scopes: vec!["https://www.googleapis.com/auth/drive".to_string()],
flat_path: None,
supports_media_download: false,
supports_media_upload: false,
media_upload: None,
},
);
let mut resources = HashMap::new();
resources.insert(
"files".to_string(),
RestResource {
methods,
resources: HashMap::new(),
},
);
RestDescription {
name: "drive".to_string(),
version: "v3".to_string(),
title: None,
description: None,
root_url: "".to_string(),
service_path: "".to_string(),
base_url: None,
schemas: HashMap::new(),
resources,
parameters: HashMap::new(),
auth: None,
}
}
#[test]
fn test_all_commands_always_shown() {
let doc = make_doc();
let cmd = build_cli(&doc);
// Should have "files" subcommand
let files_cmd = cmd
.find_subcommand("files")
.expect("files resource missing");
// All methods should always be visible regardless of auth state
assert!(files_cmd.find_subcommand("list").is_some());
assert!(files_cmd.find_subcommand("delete").is_some());
}
#[test]
fn test_sanitize_arg_present() {
let doc = make_doc();
let cmd = build_cli(&doc);
// The --sanitize global arg should be available
let args: Vec<_> = cmd.get_arguments().collect();
let sanitize_arg = args.iter().find(|a| a.get_id() == "sanitize");
assert!(
sanitize_arg.is_some(),
"--sanitize arg should be present on root command"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Discovery Document types and fetching.
//!
//! Types are re-exported from the `google_workspace` library crate.
//! The CLI wrapper provides default caching via `config_dir()`.
pub use google_workspace::discovery::*;
/// Fetches and caches a Google Discovery Document using the CLI's config directory.
///
/// This is a convenience wrapper around
/// [`google_workspace::discovery::fetch_discovery_document`] that automatically
/// uses the CLI's cache directory (`~/.config/gws/cache/`).
pub async fn fetch_discovery_document(
service: &str,
version: &str,
) -> anyhow::Result<RestDescription> {
let cache_dir = crate::auth_commands::config_dir().join("cache");
google_workspace::discovery::fetch_discovery_document(service, version, Some(&cache_dir)).await
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Structured error types and CLI error output.
//!
//! Core error types are re-exported from the `google_workspace` library crate.
//! CLI-specific error formatting (colored terminal output) is defined here.
pub use google_workspace::error::*;
use crate::output::{colorize, sanitize_for_terminal};
/// 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"),
];
/// Format a colored error label for the given error variant.
fn error_label(err: &GwsError) -> String {
match err {
GwsError::Api { .. } => colorize("error[api]:", "31"), // red
GwsError::Auth(_) => colorize("error[auth]:", "31"), // red
GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow
GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red
GwsError::Other(_) => colorize("error:", "31"), // red
}
}
/// Formats any error as a JSON object and prints to stdout.
///
/// A human-readable colored label is printed to stderr when connected to a
/// TTY. For `accessNotConfigured` errors (HTTP 403, reason
/// `accessNotConfigured`), additional guidance is printed to stderr.
/// The JSON output on stdout is unchanged (machine-readable).
pub fn print_error_json(err: &GwsError) {
let json = err.to_json();
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
// Print a colored summary to stderr. For accessNotConfigured errors,
// print specialized guidance instead of the generic message to avoid
// redundant output (the full API error already appears in the JSON).
if let GwsError::Api {
reason, enable_url, ..
} = err
{
if reason == "accessNotConfigured" {
eprintln!();
let hint = colorize("hint:", "36"); // cyan
eprintln!(
"{} {hint} API not enabled for your GCP project.",
error_label(err)
);
if let Some(url) = enable_url {
eprintln!(" Enable it at: {url}");
} else {
eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API.");
}
eprintln!(" After enabling, wait a few seconds and retry your command.");
return;
}
}
eprintln!(
"{} {}",
error_label(err),
sanitize_for_terminal(&err.to_string())
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[serial_test::serial]
fn test_colorize_respects_no_color_env() {
std::env::set_var("NO_COLOR", "1");
let result = colorize("hello", "31");
std::env::remove_var("NO_COLOR");
assert_eq!(result, "hello");
}
#[test]
fn test_error_label_contains_variant_name() {
let api_err = GwsError::Api {
code: 400,
message: "bad".to_string(),
reason: "r".to_string(),
enable_url: None,
};
let label = error_label(&api_err);
assert!(label.contains("error[api]:"));
let auth_err = GwsError::Auth("fail".to_string());
assert!(error_label(&auth_err).contains("error[auth]:"));
let val_err = GwsError::Validation("bad input".to_string());
assert!(error_label(&val_err).contains("error[validation]:"));
let disc_err = GwsError::Discovery("missing".to_string());
assert!(error_label(&disc_err).contains("error[discovery]:"));
let other_err = GwsError::Other(anyhow::anyhow!("oops"));
assert!(error_label(&other_err).contains("error:"));
}
#[test]
fn test_sanitize_for_terminal_strips_control_chars() {
let input = "normal \x1b[31mred text\x1b[0m end";
let sanitized = sanitize_for_terminal(input);
assert_eq!(sanitized, "normal [31mred text[0m end");
assert!(!sanitized.contains('\x1b'));
let input2 = "line1\nline2\ttab";
assert_eq!(sanitize_for_terminal(input2), "line1\nline2\ttab");
let input3 = "hello\x07bell\x08backspace";
assert_eq!(sanitize_for_terminal(input3), "hellobellbackspace");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Output Formatting
//!
//! Transforms JSON API responses into human-readable formats (table, YAML, CSV).
use serde_json::Value;
use std::fmt::Write;
/// Supported output formats.
#[derive(Debug, Clone, PartialEq, Default)]
pub enum OutputFormat {
/// Pretty-printed JSON (default).
#[default]
Json,
/// Aligned text table.
Table,
/// YAML.
Yaml,
/// Comma-separated values.
Csv,
}
impl OutputFormat {
/// Parse from a string argument.
///
/// Returns `Ok(format)` for known values, or `Err(unknown_value)` if the
/// string is not recognised. Call sites should warn the user on `Err` and
/// decide whether to fall back to JSON or surface an error.
pub fn parse(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"table" => Ok(Self::Table),
"yaml" | "yml" => Ok(Self::Yaml),
"csv" => Ok(Self::Csv),
other => Err(other.to_string()),
}
}
/// Parse from a string argument, falling back to JSON for unknown values.
///
/// Prefer `parse()` at call sites where you want to surface a warning.
pub fn from_str(s: &str) -> Self {
Self::parse(s).unwrap_or(Self::Json)
}
}
/// Format a JSON value according to the specified output format.
pub fn format_value(value: &Value, format: &OutputFormat) -> String {
match format {
OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(),
OutputFormat::Table => format_table(value),
OutputFormat::Yaml => format_yaml(value),
OutputFormat::Csv => format_csv(value),
}
}
/// Format a JSON value for a paginated page.
///
/// When auto-paginating with `--page-all`, CSV and table formats should only
/// emit column headers on the **first** page so that each subsequent page
/// contains only data rows, making the combined output machine-parseable.
///
/// For JSON the output is compact (one JSON object per line / NDJSON).
/// For YAML each page is prefixed with a `---` document separator so the
/// combined stream is a valid YAML multi-document file.
pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String {
match format {
OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(),
OutputFormat::Csv => format_csv_page(value, is_first_page),
OutputFormat::Table => format_table_page(value, is_first_page),
// Prefix every page with a YAML document separator so that the
// concatenated stream is parseable as a multi-document YAML file.
OutputFormat::Yaml => format!("---\n{}", format_yaml(value)),
}
}
/// Extract a "data array" from a typical Google API list response.
/// Google APIs return lists as `{ "files": [...], "nextPageToken": "..." }`
/// where the array key varies by resource type.
fn extract_items(value: &Value) -> Option<(&str, &Vec<Value>)> {
if let Value::Object(obj) = value {
for (key, val) in obj {
if key == "nextPageToken" || key == "kind" || key.starts_with('_') {
continue;
}
if let Value::Array(arr) = val {
if !arr.is_empty() {
return Some((key, arr));
}
}
}
}
None
}
fn format_table(value: &Value) -> String {
format_table_page(value, true)
}
/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs.
///
/// Nested objects become `parent.child` key names so that `--format table` can
/// render them as individual columns instead of raw JSON blobs.
fn flatten_object(obj: &serde_json::Map<String, Value>, prefix: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for (key, val) in obj {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match val {
Value::Object(nested) => {
out.extend(flatten_object(nested, &full_key));
}
_ => {
out.push((full_key, value_to_cell(val)));
}
}
}
out
}
/// Format as a text table, optionally omitting the header row.
///
/// Pass `emit_header = false` for continuation pages when using `--page-all`
/// so the combined terminal output doesn't repeat column names and separator
/// lines between pages.
fn format_table_page(value: &Value, emit_header: bool) -> String {
// Try to extract a list of items from standard Google API response
let items = extract_items(value);
if let Some((_key, arr)) = items {
format_array_as_table(arr, emit_header)
} else if let Value::Array(arr) = value {
format_array_as_table(arr, emit_header)
} else if let Value::Object(obj) = value {
// Single object: key/value table — flatten nested objects first
let mut output = String::new();
let flat = flatten_object(obj, "");
let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, val_str) in &flat {
let _ = writeln!(output, "{:width$} {}", key, val_str, width = max_key_len);
}
output
} else {
value.to_string()
}
}
fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
if arr.is_empty() {
return "(empty)\n".to_string();
}
// Flatten each row so nested objects become dot-notation columns.
let flat_rows: Vec<Vec<(String, String)>> = arr
.iter()
.map(|item| match item {
Value::Object(obj) => flatten_object(obj, ""),
_ => vec![(String::new(), value_to_cell(item))],
})
.collect();
// Collect all unique column names (preserving insertion order).
let mut columns: Vec<String> = Vec::new();
for row in &flat_rows {
for (key, _) in row {
if !columns.contains(key) {
columns.push(key.clone());
}
}
}
if columns.is_empty() {
// Array of non-objects
let mut output = String::new();
for item in arr {
let _ = writeln!(output, "{}", value_to_cell(item));
}
return output;
}
// Build lookup: row_index -> column_name -> cell_value
let row_maps: Vec<std::collections::HashMap<&str, &str>> = flat_rows
.iter()
.map(|pairs| {
pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
})
.collect();
// Calculate column widths (char-count, not byte-count).
let mut widths: Vec<usize> = columns.iter().map(|c| c.chars().count()).collect();
let rows: Vec<Vec<String>> = row_maps
.iter()
.map(|row| {
columns
.iter()
.enumerate()
.map(|(i, col)| {
let cell = row.get(col.as_str()).copied().unwrap_or("").to_string();
let char_len = cell.chars().count();
if char_len > widths[i] {
widths[i] = char_len;
}
// Cap column width at 60 chars
if widths[i] > 60 {
widths[i] = 60;
}
cell
})
.collect()
})
.collect();
let mut output = String::new();
if emit_header {
// Header
let header: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, c)| format!("{:width$}", c, width = widths[i]))
.collect();
let _ = writeln!(output, "{}", header.join(" "));
// Separator
let sep: Vec<String> = widths.iter().map(|w| "".repeat(*w)).collect();
let _ = writeln!(output, "{}", sep.join(" "));
}
// Rows — truncate by char count to avoid panicking on multi-byte UTF-8.
for row in &rows {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(i, c)| {
let char_len = c.chars().count();
let truncated = if char_len > widths[i] {
// Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis.
let truncated_str: String = c.chars().take(widths[i] - 1).collect();
format!("{truncated_str}")
} else {
c.clone()
};
// Pad to column width (by char count)
let pad = widths[i].saturating_sub(truncated.chars().count());
format!("{truncated}{}", " ".repeat(pad))
})
.collect();
let _ = writeln!(output, "{}", cells.join(" "));
}
output
}
fn format_yaml(value: &Value) -> String {
json_to_yaml(value, 0)
}
fn json_to_yaml(value: &Value, indent: usize) -> String {
let prefix = " ".repeat(indent);
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
if s.contains('\n') {
// Genuine multi-line content: block scalar is the most readable choice.
format!(
"|\n{}",
s.lines()
.map(|l| format!("{prefix} {l}"))
.collect::<Vec<_>>()
.join("\n")
)
} else {
// Single-line strings: always double-quote so that characters like
// `#` (comment marker) and `:` (mapping indicator) are never
// misinterpreted by YAML parsers. Escape backslashes and double
// quotes to keep the output valid.
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
}
Value::Array(arr) => {
if arr.is_empty() {
return "[]".to_string();
}
let mut out = String::new();
for item in arr {
let val_str = json_to_yaml(item, indent + 1);
let _ = write!(out, "\n{prefix}- {val_str}");
}
out
}
Value::Object(obj) => {
if obj.is_empty() {
return "{}".to_string();
}
let mut out = String::new();
for (key, val) in obj {
match val {
Value::Object(_) | Value::Array(_) => {
let val_str = json_to_yaml(val, indent + 1);
let _ = write!(out, "\n{prefix}{key}:{val_str}");
}
_ => {
let val_str = json_to_yaml(val, indent);
let _ = write!(out, "\n{prefix}{key}: {val_str}");
}
}
}
out
}
}
}
fn format_csv(value: &Value) -> String {
format_csv_page(value, true)
}
/// Format as CSV, optionally omitting the header row.
///
/// Pass `emit_header = false` for all pages after the first when using
/// `--page-all`, so the combined output has a single header line.
fn format_csv_page(value: &Value, emit_header: bool) -> String {
let items = extract_items(value);
let arr = if let Some((_key, arr)) = items {
arr.as_slice()
} else if let Value::Array(arr) = value {
arr.as_slice()
} else {
// Single value — just output it
return value_to_cell(value);
};
if arr.is_empty() {
return String::new();
}
// Array of non-objects
if !arr.iter().any(|v| v.is_object()) {
let mut output = String::new();
for item in arr {
if let Value::Array(inner) = item {
let cells: Vec<String> = inner
.iter()
.map(|v| csv_escape(&value_to_cell(v)))
.collect();
let _ = writeln!(output, "{}", cells.join(","));
} else {
let _ = writeln!(output, "{}", csv_escape(&value_to_cell(item)));
}
}
return output;
}
// Collect columns
let mut columns: Vec<String> = Vec::new();
for item in arr {
if let Value::Object(obj) = item {
for key in obj.keys() {
if !columns.contains(key) {
columns.push(key.clone());
}
}
}
}
let mut output = String::new();
// Header (omitted on continuation pages)
if emit_header {
let _ = writeln!(output, "{}", columns.join(","));
}
// Rows
for item in arr {
let cells: Vec<String> = columns
.iter()
.map(|col| {
if let Value::Object(obj) = item {
csv_escape(&value_to_cell(obj.get(col).unwrap_or(&Value::Null)))
} else {
String::new()
}
})
.collect();
let _ = writeln!(output, "{}", cells.join(","));
}
output
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn value_to_cell(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(value_to_cell).collect();
items.join(", ")
}
Value::Object(_) => serde_json::to_string(value).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_output_format_from_str() {
assert_eq!(OutputFormat::from_str("json"), OutputFormat::Json);
assert_eq!(OutputFormat::from_str("table"), OutputFormat::Table);
assert_eq!(OutputFormat::from_str("yaml"), OutputFormat::Yaml);
assert_eq!(OutputFormat::from_str("yml"), OutputFormat::Yaml);
assert_eq!(OutputFormat::from_str("csv"), OutputFormat::Csv);
assert_eq!(OutputFormat::from_str("unknown"), OutputFormat::Json);
}
#[test]
fn test_output_format_parse_known() {
assert_eq!(OutputFormat::parse("json"), Ok(OutputFormat::Json));
assert_eq!(OutputFormat::parse("table"), Ok(OutputFormat::Table));
assert_eq!(OutputFormat::parse("yaml"), Ok(OutputFormat::Yaml));
assert_eq!(OutputFormat::parse("yml"), Ok(OutputFormat::Yaml));
assert_eq!(OutputFormat::parse("csv"), Ok(OutputFormat::Csv));
// Case-insensitive
assert_eq!(OutputFormat::parse("JSON"), Ok(OutputFormat::Json));
assert_eq!(OutputFormat::parse("TABLE"), Ok(OutputFormat::Table));
}
#[test]
fn test_output_format_parse_unknown_returns_err() {
assert!(OutputFormat::parse("bogus").is_err());
assert_eq!(OutputFormat::parse("bogus").unwrap_err(), "bogus");
assert!(OutputFormat::parse("").is_err());
}
#[test]
fn test_format_json() {
let val = json!({"name": "test"});
let output = format_value(&val, &OutputFormat::Json);
assert!(output.contains("\"name\""));
assert!(output.contains("\"test\""));
}
#[test]
fn test_format_table_array_of_objects() {
let val = json!({
"files": [
{"id": "1", "name": "hello.txt"},
{"id": "2", "name": "world.txt"}
]
});
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("id"));
assert!(output.contains("name"));
assert!(output.contains("hello.txt"));
assert!(output.contains("world.txt"));
// Check separator line
assert!(output.contains("──"));
}
#[test]
fn test_format_table_single_object() {
let val = json!({"id": "abc", "name": "test"});
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("id"));
assert!(output.contains("abc"));
}
#[test]
fn test_format_table_nested_object_flattened() {
// Nested objects should become dot-notation columns, not raw JSON blobs.
let val = json!({
"user": {
"displayName": "Alice",
"emailAddress": "alice@example.com"
},
"storageQuota": {
"limit": "1000",
"usage": "500"
}
});
let output = format_value(&val, &OutputFormat::Table);
// Should contain dot-notation keys
assert!(
output.contains("user.displayName"),
"expected flattened key in output:\n{output}"
);
assert!(
output.contains("user.emailAddress"),
"expected flattened key in output:\n{output}"
);
assert!(
output.contains("Alice"),
"expected value in output:\n{output}"
);
// Should NOT contain raw JSON blobs
assert!(
!output.contains("{\"displayName"),
"should not have raw JSON blob:\n{output}"
);
}
#[test]
fn test_format_table_nested_objects_in_array() {
let val = json!([
{"id": "1", "owner": {"name": "Alice"}},
{"id": "2", "owner": {"name": "Bob"}}
]);
let output = format_value(&val, &OutputFormat::Table);
assert!(
output.contains("owner.name"),
"expected flattened column:\n{output}"
);
assert!(output.contains("Alice"), "expected value:\n{output}");
assert!(output.contains("Bob"), "expected value:\n{output}");
}
#[test]
fn test_format_table_multibyte_truncation_does_not_panic() {
// Column width cap is 60 chars, so a long string with multi-byte chars
// must be safely truncated without a byte-boundary panic.
let long_emoji = "😀".repeat(70); // each emoji is 4 bytes
let val = json!([{"col": long_emoji}]);
// Should not panic
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("col"), "column name must appear:\n{output}");
}
#[test]
fn test_format_table_multibyte_exact_boundary() {
// Multi-byte chars at various positions must not panic or produce garbled output.
let val = json!([{"name": "café résumé naïve"}]);
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("name"), "column must appear:\n{output}");
}
#[test]
fn test_format_csv() {
let val = json!({
"files": [
{"id": "1", "name": "hello"},
{"id": "2", "name": "world"}
]
});
let output = format_value(&val, &OutputFormat::Csv);
assert!(output.contains("id,name"));
assert!(output.contains("1,hello"));
assert!(output.contains("2,world"));
}
#[test]
fn test_format_csv_array_of_arrays() {
// Sheets API returns {"values": [["col1","col2"], ["a","b"]]}
let val = json!({
"values": [
["Student Name", "Gender", "Class Level"],
["Alexandra", "Female", "4. Senior"],
["Andrew", "Male", "1. Freshman"]
]
});
let output = format_value(&val, &OutputFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "Student Name,Gender,Class Level");
assert_eq!(lines[1], "Alexandra,Female,4. Senior");
assert_eq!(lines[2], "Andrew,Male,1. Freshman");
}
#[test]
fn test_format_csv_flat_scalars() {
// Flat array of non-object, non-array values → one value per line
let val = json!(["apple", "banana", "cherry"]);
let output = format_value(&val, &OutputFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "apple");
assert_eq!(lines[1], "banana");
assert_eq!(lines[2], "cherry");
}
#[test]
fn test_format_csv_flat_scalars_with_escaping() {
// Scalars that contain commas/quotes must be CSV-escaped
let val = json!(["plain", "has,comma", "has\"quote"]);
let output = format_value(&val, &OutputFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "plain");
assert_eq!(lines[1], "\"has,comma\"");
assert_eq!(lines[2], "\"has\"\"quote\"");
}
#[test]
fn test_format_csv_escape() {
assert_eq!(csv_escape("simple"), "simple");
assert_eq!(csv_escape("has,comma"), "\"has,comma\"");
assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\"");
}
#[test]
fn test_format_yaml() {
let val = json!({"name": "test", "count": 42});
let output = format_value(&val, &OutputFormat::Yaml);
assert!(output.contains("name: \"test\""));
assert!(output.contains("count: 42"));
}
#[test]
fn test_format_table_empty_array() {
let val = json!({"files": []});
// No items to extract, falls back to single-object table
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("files"));
}
#[test]
fn test_extract_items() {
let val = json!({"files": [{"id": "1"}], "nextPageToken": "abc"});
let (key, items) = extract_items(&val).unwrap();
assert_eq!(key, "files");
assert_eq!(items.len(), 1);
}
#[test]
fn test_extract_items_none() {
let val = json!({"status": "ok"});
assert!(extract_items(&val).is_none());
}
// --- YAML block-scalar regression tests ---
#[test]
fn test_format_yaml_hash_in_string_is_quoted_not_block() {
// `drive#file` contains `#` which is a YAML comment marker; the
// serialiser must quote it rather than emit a block scalar.
let val = json!({"kind": "drive#file", "id": "123"});
let output = format_value(&val, &OutputFormat::Yaml);
// Must be a double-quoted string, not a block scalar (`|`).
assert!(
output.contains("kind: \"drive#file\""),
"expected double-quoted kind, got:\n{output}"
);
assert!(
!output.contains("kind: |"),
"kind must not use block scalar, got:\n{output}"
);
}
#[test]
fn test_format_yaml_colon_in_string_is_quoted() {
let val = json!({"url": "https://example.com/path"});
let output = format_value(&val, &OutputFormat::Yaml);
assert!(
output.contains("url: \"https://example.com/path\""),
"expected double-quoted url, got:\n{output}"
);
assert!(!output.contains("url: |"), "url must not use block scalar");
}
#[test]
fn test_format_yaml_multiline_still_uses_block() {
let val = json!({"body": "line one\nline two"});
let output = format_value(&val, &OutputFormat::Yaml);
// Multi-line content should still use block scalar.
assert!(
output.contains("body: |"),
"multiline string must use block scalar, got:\n{output}"
);
}
// --- Paginated format tests ---
#[test]
fn test_format_value_paginated_csv_first_page_has_header() {
let val = json!({
"files": [
{"id": "1", "name": "a.txt"},
{"id": "2", "name": "b.txt"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Csv, true);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "id,name", "first page must start with header");
assert_eq!(lines[1], "1,a.txt");
}
#[test]
fn test_format_value_paginated_csv_continuation_no_header() {
let val = json!({
"files": [
{"id": "3", "name": "c.txt"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Csv, false);
let lines: Vec<&str> = output.lines().collect();
// The first (and only) line must be a data row, not the header.
assert_eq!(lines[0], "3,c.txt", "continuation page must have no header");
assert!(
!output.contains("id,name"),
"header must be absent on continuation pages"
);
}
#[test]
fn test_format_value_paginated_table_first_page_has_header() {
let val = json!({
"items": [
{"id": "1", "name": "foo"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Table, true);
assert!(
output.contains("id"),
"table header must appear on first page"
);
assert!(output.contains("──"), "separator must appear on first page");
}
#[test]
fn test_format_value_paginated_table_continuation_no_header() {
let val = json!({
"items": [
{"id": "2", "name": "bar"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Table, false);
assert!(output.contains("bar"), "data row must be present");
assert!(
!output.contains("──"),
"separator must be absent on continuation pages"
);
}
#[test]
fn test_format_value_paginated_yaml_has_document_separator() {
let val = json!({"files": [{"id": "1", "name": "foo"}]});
let first = format_value_paginated(&val, &OutputFormat::Yaml, true);
let second = format_value_paginated(&val, &OutputFormat::Yaml, false);
assert!(
first.starts_with("---\n"),
"first YAML page must start with ---"
);
assert!(
second.starts_with("---\n"),
"continuation YAML pages must also start with ---"
);
}
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! File-system utilities.
use std::io::{self, Write};
use std::path::Path;
/// Write `data` to `path` atomically.
///
/// This implementation uses `tempfile::NamedTempFile` to create a temporary
/// file with a random name, `O_EXCL` flags (preventing symlink attacks),
/// and secure 0600 permissions from the moment of creation.
///
/// # Errors
///
/// Returns an `io::Error` if the temporary file cannot be created/written or if the
/// final rename fails.
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
})?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(data)?;
tmp.as_file().sync_all()?;
tmp.persist(path)
.map_err(|e| io::Error::new(e.error.kind(), e.error))?;
Ok(())
}
/// Async variant of [`atomic_write`] for use with tokio.
///
/// This implementation uses `create_new(true)` (O_EXCL) and `mode(0o600)` to
/// prevent TOCTOU/symlink race conditions.
pub async fn atomic_write_async(path: &Path, data: &[u8]) -> io::Result<()> {
use rand::Rng;
use tokio::io::AsyncWriteExt;
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
})?;
let file_name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
.to_string_lossy();
let mut retries = 0;
let mut file: tokio::fs::File;
let mut tmp_path;
loop {
let suffix: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(8)
.map(char::from)
.collect();
let tmp_name = format!("{}.tmp.{}", file_name, suffix);
tmp_path = parent.join(tmp_name);
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
opts.mode(0o600);
}
match opts.open(&tmp_path).await {
Ok(f) => {
file = f;
break;
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists && retries < 10 => {
retries += 1;
continue;
}
Err(e) => return Err(e),
}
}
let write_result = async {
file.write_all(data).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&tmp_path, path).await
}
.await;
if write_result.is_err() {
let _ = tokio::fs::remove_file(&tmp_path).await;
}
write_result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_atomic_write_creates_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
atomic_write(&path, b"hello").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}
#[test]
fn test_atomic_write_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
fs::write(&path, b"old").unwrap();
atomic_write(&path, b"new").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
}
#[test]
fn test_atomic_write_leaves_no_tmp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
atomic_write(&path, b"data").unwrap();
// Since we use random names, we just check that no .tmp files remain in the dir
let files: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.map(|res| res.unwrap().file_name())
.collect();
assert_eq!(files.len(), 1);
assert_eq!(files[0], "credentials.enc");
}
#[tokio::test]
async fn test_atomic_write_async_creates_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token_cache.json");
atomic_write_async(&path, b"async hello").await.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"async hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
# Helper Commands (`+verb`) — Guidelines
## Design Principle
The core design of `gws` is **schema-driven**: commands are dynamically generated from Google Discovery Documents at runtime. This avoids maintaining a hardcoded, unbounded argument surface. **Helpers must complement this design, not duplicate it.**
## When a Helper is Justified
A `+helper` command should exist only when it provides value that Discovery-based commands **cannot**:
| Justification | Example | Why Discovery Can't Do It |
|---|---|---|
| **Multi-step orchestration** | `+subscribe` | Creates Pub/Sub topic → subscription → Workspace Events subscription (3 APIs) |
| **Format translation** | `+write` | Transforms Markdown → Docs `batchUpdate` JSON |
| **Multi-API composition** | `+triage` | Lists messages then fetches N metadata payloads concurrently |
| **Complex body construction** | `+send`, `+reply` | Builds RFC 2822 MIME from simple flags |
| **Multipart upload** | `+upload` | Handles resumable upload protocol with progress |
| **Workflow recipes** | `+standup-report` | Chains calls across multiple services |
**Litmus test:** Can the user achieve the same result with `gws <service> <resource> <method> --params '{...}'`? If yes, don't add a helper.
## Anti-Patterns
### ❌ Anti-pattern 1: Single API Call Wrapper
If a helper wraps one API call that Discovery already exposes, reject it.
**Real example:** `+revisions` (PR #563) wrapped `gws drive files-revisions list` — same single API call, zero added value.
### ❌ Anti-pattern 2: Unbounded Flag Accumulation
Adding flags to expose data that is already in the API response creates unbounded surface area.
**Real example:** `--thread-id`, `--delivered-to`, `--sent-last` on `+triage` (PR #597) — all three values are already present in the Gmail API response. Agents and users should extract them with `--format` or `jq`, not new flags.
**Why this is harmful:** Every API response contains dozens of fields. If we add a flag for each one, helpers become unbounded maintenance burdens — the exact problem Discovery-driven design solves.
### ❌ Anti-pattern 3: Duplicating Discovery Parameters
Don't re-expose Discovery-defined parameters (e.g., `pageSize`, `fields`, `orderBy`) as custom helper flags. Use `--params` passthrough instead.
## Flag Design Rules
Helper flags must control **orchestration logic**, not API parameters or output fields.
### ✅ Good Flags (control orchestration)
| Flag | Helper | Why It's Good |
|---|---|---|
| `--spreadsheet`, `--range` | `+read` | Identifies which resource to operate on |
| `--to`, `--subject`, `--body` | `+send` | Inputs to MIME construction (format translation) |
| `--dry-run` | `+subscribe` | Controls whether API calls are actually made |
| `--subscription` | `+subscribe` | Switches between "create new" vs. "use existing" orchestration path |
| `--target`, `--project` | `+subscribe` | Required for multi-service resource creation |
### ❌ Bad Flags (expose API response data)
| Flag | Why It's Bad | Alternative |
|---|---|---|
| `--thread-id` | Already in API response | `jq '.threadId'` |
| `--delivered-to` | Already in response headers | `jq '.payload.headers[] | ...'` |
| `--include-labels` | Output field filtering | `--format` or `jq` |
### Decision Checklist for New Flags
1. Does this flag control **what API call to make** or **how to orchestrate** multiple calls? → ✅ Add it
2. Does this flag control **what data appears in output**? → ❌ Use `--format`/`jq`
3. Does this flag duplicate a Discovery parameter? → ❌ Use `--params`
4. Could the user achieve this with existing flags + post-processing? → ❌ Don't add it
## Architecture
Helpers are implemented using the `Helper` trait defined in `mod.rs`.
- **`inject_commands`**: Adds subcommands to the main service command. All helper commands are always shown regardless of authentication state.
- **`handle`**: Implementation of the command logic. Returns `Ok(true)` if the command was handled, or `Ok(false)` to let the default raw resource handler attempt to handle it.
## Adding a New Helper — Checklist
1. **Passes the litmus test** — cannot be done with a single Discovery command
2. **Flags are bounded** — only flags controlling orchestration, not API params/output
3. **Uses shared infrastructure:**
- `crate::client::build_client()` for HTTP
- `crate::validate::validate_resource_name()` for user-supplied resource IDs
- `crate::validate::encode_path_segment()` for URL path segments
- `crate::output::sanitize_for_terminal()` for error messages
4. **Has tests** — at minimum: command registration, required args, happy path
5. **Supports `--dry-run`** where the helper creates or mutates resources
### Development Steps
1. Create `src/helpers/<service>.rs`
2. Implement the `Helper` trait
3. Register it in `src/helpers/mod.rs`
4. **Prefix** the command with `+` (e.g., `+create`)
@@ -0,0 +1,772 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::json;
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
pub struct CalendarHelper;
impl Helper for CalendarHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+insert")
.about("[Helper] create a new event")
.arg(
Arg::new("calendar")
.long("calendar")
.help("Calendar ID (default: primary)")
.default_value("primary")
.value_name("ID"),
)
.arg(
Arg::new("summary")
.long("summary")
.help("Event summary/title")
.required(true)
.value_name("TEXT"),
)
.arg(
Arg::new("start")
.long("start")
.help("Start time (ISO 8601, e.g., 2024-01-01T10:00:00Z)")
.required(true)
.value_name("TIME"),
)
.arg(
Arg::new("end")
.long("end")
.help("End time (ISO 8601)")
.required(true)
.value_name("TIME"),
)
.arg(
Arg::new("location")
.long("location")
.help("Event location")
.value_name("TEXT"),
)
.arg(
Arg::new("description")
.long("description")
.help("Event description/body")
.value_name("TEXT"),
)
.arg(
Arg::new("attendee")
.long("attendee")
.help("Attendee email (can be used multiple times)")
.value_name("EMAIL")
.action(ArgAction::Append),
)
.arg(
Arg::new("meet")
.long("meet")
.help("Add a Google Meet video conference link")
.action(ArgAction::SetTrue),
)
.after_help("\
EXAMPLES:
gws calendar +insert --summary 'Standup' --start '2026-06-17T09:00:00-07:00' --end '2026-06-17T09:30:00-07:00'
gws calendar +insert --summary 'Review' --start ... --end ... --attendee alice@example.com
gws calendar +insert --summary 'Meet' --start ... --end ... --meet
TIPS:
Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
The --meet flag automatically adds a Google Meet link to the event."),
);
cmd = cmd.subcommand(
Command::new("+agenda")
.about("[Helper] Show upcoming events across all calendars")
.arg(
Arg::new("today")
.long("today")
.help("Show today's events")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("tomorrow")
.long("tomorrow")
.help("Show tomorrow's events")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("week")
.long("week")
.help("Show this week's events")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("days")
.long("days")
.help("Number of days ahead to show")
.value_name("N"),
)
.arg(
Arg::new("calendar")
.long("calendar")
.help("Filter to specific calendar name or ID")
.value_name("NAME"),
)
.arg(
Arg::new("timezone")
.long("timezone")
.alias("tz")
.help("IANA timezone override (e.g. America/Denver). Defaults to Google account timezone.")
.value_name("TZ"),
)
.after_help(
"\
EXAMPLES:
gws calendar +agenda
gws calendar +agenda --today
gws calendar +agenda --week --format table
gws calendar +agenda --days 3 --calendar 'Work'
gws calendar +agenda --today --timezone America/New_York
TIPS:
Read-only — never modifies events.
Queries all calendars by default; use --calendar to filter.
Uses your Google account timezone by default; override with --timezone.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+insert") {
let (params_str, body_str, scopes) = build_insert_request(matches, doc)?;
let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes_str).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Calendar auth failed: {e}"))),
};
let events_res = doc.resources.get("events").ok_or_else(|| {
GwsError::Discovery("Resource 'events' not found".to_string())
})?;
let insert_method = events_res.methods.get("insert").ok_or_else(|| {
GwsError::Discovery("Method 'events.insert' not found".to_string())
})?;
executor::execute_method(
doc,
insert_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+agenda") {
handle_agenda(matches).await?;
return Ok(true);
}
Ok(false)
})
}
}
async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let token = auth::get_token(&[cal_scope])
.await
.map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?;
let output_format = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
let client = crate::client::build_client()?;
let tz_override = matches.get_one::<String>("timezone").map(|s| s.as_str());
let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?;
// Determine time range using the account timezone so that --today and
// --tomorrow align with the user's Google account day, not the machine.
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;
let days: i64 = if matches.get_flag("tomorrow") {
1
} else if matches.get_flag("week") {
7
} else {
matches
.get_one::<String>("days")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(1)
};
let (time_min_dt, time_max_dt) = if matches.get_flag("today") {
// Today: account tz midnight to midnight+1
let end = today_start_tz + chrono::Duration::days(1);
(today_start_tz, end)
} else if matches.get_flag("tomorrow") {
// Tomorrow: account tz midnight+1 to midnight+2
let start = today_start_tz + chrono::Duration::days(1);
let end = today_start_tz + chrono::Duration::days(2);
(start, end)
} else {
// From now, N days ahead
let end = now_in_tz + chrono::Duration::days(days);
(now_in_tz, end)
};
let time_min = time_min_dt.to_rfc3339();
let time_max = time_max_dt.to_rfc3339();
// client already built above for timezone resolution
let calendar_filter = matches.get_one::<String>("calendar");
// 1. List all calendars
let list_url = "https://www.googleapis.com/calendar/v3/users/me/calendarList";
let list_resp = client
.get(list_url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list calendars: {e}")))?;
if !list_resp.status().is_success() {
let err = list_resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 0,
message: err,
reason: "calendarList_failed".to_string(),
enable_url: None,
});
}
let list_json: Value = list_resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse calendar list: {e}")))?;
let calendars = list_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
// 2. For each calendar, fetch events concurrently
use futures_util::stream::{self, StreamExt};
// Pre-filter calendars and collect owned data to avoid lifetime issues
struct CalInfo {
id: String,
summary: String,
}
let filtered_calendars: Vec<CalInfo> = calendars
.iter()
.filter_map(|cal| {
let cal_id = cal.get("id").and_then(|v| v.as_str()).unwrap_or("");
let cal_summary = cal
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or(cal_id);
// Apply calendar filter
if let Some(filter) = calendar_filter {
if !cal_summary.contains(filter.as_str()) && cal_id != filter.as_str() {
return None;
}
}
Some(CalInfo {
id: cal_id.to_string(),
summary: cal_summary.to_string(),
})
})
.collect();
let mut all_events: Vec<Value> = stream::iter(filtered_calendars)
.map(|cal| {
let client = &client;
let token = &token;
let time_min = &time_min;
let time_max = &time_max;
async move {
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
crate::validate::encode_path_segment(&cal.id),
);
let resp = crate::client::send_with_retry(|| {
client
.get(&events_url)
.query(&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "50"),
])
.bearer_auth(token)
})
.await;
let resp = match resp {
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
let events_json: Value = match resp.json().await {
Ok(v) => v,
Err(_) => return vec![],
};
let mut events = Vec::new();
if let Some(items) = events_json.get("items").and_then(|i| i.as_array()) {
for event in items {
let start = event
.get("start")
.and_then(|s| s.get("dateTime").or_else(|| s.get("date")))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let end = event
.get("end")
.and_then(|s| s.get("dateTime").or_else(|| s.get("date")))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let summary = event
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("(No title)")
.to_string();
let location = event
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
events.push(json!({
"start": start,
"end": end,
"summary": summary,
"calendar": cal.summary,
"location": location,
}));
}
}
events
}
})
.buffer_unordered(5)
.flat_map(stream::iter)
.collect()
.await;
// 3. Sort by start time
all_events.sort_by(|a, b| {
let a_start = a.get("start").and_then(|v| v.as_str()).unwrap_or("");
let b_start = b.get("start").and_then(|v| v.as_str()).unwrap_or("");
a_start.cmp(b_start)
});
let output = json!({
"events": all_events,
"count": all_events.len(),
"timeMin": time_min,
"timeMax": time_max,
});
println!(
"{}",
crate::formatter::format_value(&output, &output_format)
);
Ok(())
}
fn build_insert_request(
matches: &ArgMatches,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let calendar_id = matches.get_one::<String>("calendar").unwrap();
let summary = matches.get_one::<String>("summary").unwrap();
let start = matches.get_one::<String>("start").unwrap();
let end = matches.get_one::<String>("end").unwrap();
let location = matches.get_one::<String>("location");
let description = matches.get_one::<String>("description");
let attendees_vals = matches.get_many::<String>("attendee");
// Find method: events.insert checks
let events_res = doc
.resources
.get("events")
.ok_or_else(|| GwsError::Discovery("Resource 'events' not found".to_string()))?;
let insert_method = events_res
.methods
.get("insert")
.ok_or_else(|| GwsError::Discovery("Method 'events.insert' not found".to_string()))?;
// Build body
let mut body = json!({
"summary": summary,
"start": { "dateTime": start },
"end": { "dateTime": end },
});
if let Some(loc) = location {
body["location"] = json!(loc);
}
if let Some(desc) = description {
body["description"] = json!(desc);
}
if let Some(atts) = attendees_vals {
let attendees_list: Vec<_> = atts.map(|email| json!({ "email": email })).collect();
body["attendees"] = json!(attendees_list);
}
let mut params = json!({
"calendarId": calendar_id
});
if matches.get_flag("meet") {
let namespace = uuid::Uuid::NAMESPACE_DNS;
let mut attendees: Vec<_> = matches
.get_many::<String>("attendee")
.map(|vals| vals.cloned().collect())
.unwrap_or_default();
attendees.sort();
let seed_payload = {
let mut map = serde_json::Map::new();
map.insert("v".to_string(), json!(1));
map.insert("summary".to_string(), json!(summary));
map.insert("start".to_string(), json!(start));
map.insert("end".to_string(), json!(end));
if let Some(loc) = location {
map.insert("location".to_string(), json!(loc));
}
if let Some(desc) = description {
map.insert("description".to_string(), json!(desc));
}
if !attendees.is_empty() {
let attendees_list_for_seed: Vec<_> = attendees
.iter()
.map(|email| json!({ "email": email }))
.collect();
map.insert("attendees".to_string(), json!(attendees_list_for_seed));
}
serde_json::Value::Object(map)
};
let seed_data = serde_json::to_vec(&seed_payload).map_err(|e| {
GwsError::Other(anyhow::anyhow!(
"Failed to serialize seed payload for idempotency key: {e}"
))
})?;
let request_id = uuid::Uuid::new_v5(&namespace, &seed_data).to_string();
body["conferenceData"] = json!({
"createRequest": {
"requestId": request_id,
"conferenceSolutionKey": { "type": "hangoutsMeet" }
}
});
params["conferenceDataVersion"] = json!(1);
}
let body_str = body.to_string();
let scopes: Vec<String> = insert_method.scopes.iter().map(|s| s.to_string()).collect();
// events.insert requires 'calendarId' path parameter
let params_str = params.to_string();
Ok((params_str, body_str, scopes))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_mock_doc() -> crate::discovery::RestDescription {
let mut doc = crate::discovery::RestDescription::default();
let mut events_res = crate::discovery::RestResource::default();
let mut insert_method = crate::discovery::RestMethod::default();
insert_method.scopes.push("https://scope".to_string());
events_res
.methods
.insert("insert".to_string(), insert_method);
doc.resources.insert("events".to_string(), events_res);
doc
}
fn make_matches_insert(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(
Arg::new("calendar")
.long("calendar")
.default_value("primary"),
)
.arg(Arg::new("summary").long("summary").required(true))
.arg(Arg::new("start").long("start").required(true))
.arg(Arg::new("end").long("end").required(true))
.arg(Arg::new("location").long("location"))
.arg(Arg::new("description").long("description"))
.arg(
Arg::new("attendee")
.long("attendee")
.action(ArgAction::Append),
)
.arg(Arg::new("meet").long("meet").action(ArgAction::SetTrue));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_insert_request() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
]);
let (params, body, scopes) = build_insert_request(&matches, &doc).unwrap();
assert!(params.contains("primary"));
assert!(body.contains("Meeting"));
assert!(body.contains("2024-01-01T10:00:00Z"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_build_insert_request_with_meet() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
]);
let (params, body, _) = build_insert_request(&matches, &doc).unwrap();
let params_json: serde_json::Value = serde_json::from_str(&params).unwrap();
assert_eq!(params_json["conferenceDataVersion"], 1);
let body_json: serde_json::Value = serde_json::from_str(&body).unwrap();
let create_req = &body_json["conferenceData"]["createRequest"];
assert_eq!(create_req["conferenceSolutionKey"]["type"], "hangoutsMeet");
assert!(uuid::Uuid::parse_str(create_req["requestId"].as_str().unwrap()).is_ok());
}
#[test]
fn test_build_insert_request_with_meet_is_idempotent() {
let doc = make_mock_doc();
let args = &[
"test",
"--summary",
"Idempotent Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
];
let matches1 = make_matches_insert(args);
let (_, body1, _) = build_insert_request(&matches1, &doc).unwrap();
let matches2 = make_matches_insert(args);
let (_, body2, _) = build_insert_request(&matches2, &doc).unwrap();
let b1: serde_json::Value = serde_json::from_str(&body1).unwrap();
let b2: serde_json::Value = serde_json::from_str(&body2).unwrap();
assert_eq!(
b1["conferenceData"]["createRequest"]["requestId"],
b2["conferenceData"]["createRequest"]["requestId"],
"requestId should be deterministic for the same event details"
);
}
#[test]
fn test_build_insert_request_with_meet_idempotency_robust() {
let doc = make_mock_doc();
// Base case
let args_base = &[
"test",
"--summary",
"S",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
"--attendee",
"a@b.com",
"--attendee",
"c@d.com",
];
let (_, body_base, _) =
build_insert_request(&make_matches_insert(args_base), &doc).unwrap();
let b_base: serde_json::Value = serde_json::from_str(&body_base).unwrap();
let id_base = b_base["conferenceData"]["createRequest"]["requestId"]
.as_str()
.unwrap();
// Same but different attendee order
let args_reordered = &[
"test",
"--summary",
"S",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
"--attendee",
"c@d.com",
"--attendee",
"a@b.com",
];
let (_, body_reordered, _) =
build_insert_request(&make_matches_insert(args_reordered), &doc).unwrap();
let b_reordered: serde_json::Value = serde_json::from_str(&body_reordered).unwrap();
let id_reordered = b_reordered["conferenceData"]["createRequest"]["requestId"]
.as_str()
.unwrap();
assert_eq!(
id_base, id_reordered,
"Attendee order should not change requestId"
);
// Different summary -> different ID
let args_diff = &[
"test",
"--summary",
"Diff",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
"--attendee",
"a@b.com",
"--attendee",
"c@d.com",
];
let (_, body_diff, _) =
build_insert_request(&make_matches_insert(args_diff), &doc).unwrap();
let b_diff: serde_json::Value = serde_json::from_str(&body_diff).unwrap();
let id_diff = b_diff["conferenceData"]["createRequest"]["requestId"]
.as_str()
.unwrap();
assert_ne!(
id_base, id_diff,
"Different summary should produce different requestId"
);
}
#[test]
fn test_build_insert_request_with_optional_fields() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--location",
"Room 1",
"--description",
"Discuss stuff",
"--attendee",
"a@b.com",
"--attendee",
"c@d.com",
]);
let (_, body, _) = build_insert_request(&matches, &doc).unwrap();
assert!(body.contains("Room 1"));
assert!(body.contains("Discuss stuff"));
assert!(body.contains("a@b.com"));
assert!(body.contains("c@d.com"));
}
/// Verify that agenda day boundaries use a specific timezone, not UTC.
#[test]
fn agenda_day_boundaries_use_account_timezone() {
use chrono::{NaiveTime, TimeZone, Utc};
// Simulate using a known account timezone (America/Denver = UTC-7 / UTC-6 DST)
let tz = chrono_tz::America::Denver;
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
let today_start_tz = tz
.from_local_datetime(&today_start)
.earliest()
.expect("midnight should resolve");
let today_rfc = today_start_tz.to_rfc3339();
let tomorrow_start = today_start_tz + chrono::Duration::days(1);
let tomorrow_rfc = tomorrow_start.to_rfc3339();
// The Denver offset should appear in the RFC3339 string (-07:00 or -06:00 for DST).
// Crucially, it should NOT be +00:00 (UTC).
assert!(
today_rfc.contains("-07:00") || today_rfc.contains("-06:00"),
"today boundary should carry Denver offset, got {today_rfc}"
);
assert!(
tomorrow_rfc.contains("-07:00") || tomorrow_rfc.contains("-06:00"),
"tomorrow boundary should carry Denver offset, got {tomorrow_rfc}"
);
}
}
@@ -0,0 +1,282 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
pub struct ChatHelper;
impl Helper for ChatHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+send")
.about("[Helper] Send a message to a space")
.arg(
Arg::new("space")
.long("space")
.help("Space name (e.g. spaces/AAAA...)")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("text")
.long("text")
.help("Message text (plain text)")
.required(true)
.value_name("TEXT"),
)
.after_help(
"\
EXAMPLES:
gws chat +send --space spaces/AAAAxxxx --text 'Hello team!'
TIPS:
Use 'gws chat spaces list' to find space names.
For cards or threaded replies, use the raw API instead.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
// We use `Box::pin` to create a pinned future on the heap.
// This is necessary because the `Helper` trait returns a generic `Future`,
// and async blocks in Rust are anonymous types that need to be erased
// (via `dyn Future`) to be returned from a trait method.
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+send") {
// Parse arguments into our config struct config
let config = parse_send_args(matches)?;
// The `?` operator here will propagate any errors from `build_send_request`
// immediately, returning `Err(GwsError)` from the async block.
let (params_str, body_str, scopes) = build_send_request(&config, doc)?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Chat auth failed: {e}"))),
};
// Method: spaces.messages.create
let spaces_res = doc.resources.get("spaces").ok_or_else(|| {
GwsError::Discovery("Resource 'spaces' not found".to_string())
})?;
let messages_res = spaces_res.resources.get("messages").ok_or_else(|| {
GwsError::Discovery("Resource 'spaces.messages' not found".to_string())
})?;
let create_method = messages_res.methods.get("create").ok_or_else(|| {
GwsError::Discovery("Method 'spaces.messages.create' not found".to_string())
})?;
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
create_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn build_send_request(
config: &SendConfig,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let spaces_res = doc
.resources
.get("spaces")
.ok_or_else(|| GwsError::Discovery("Resource 'spaces' not found".to_string()))?;
let messages_res = spaces_res
.resources
.get("messages")
.ok_or_else(|| GwsError::Discovery("Resource 'spaces.messages' not found".to_string()))?;
let create_method = messages_res.methods.get("create").ok_or_else(|| {
GwsError::Discovery("Method 'spaces.messages.create' not found".to_string())
})?;
let params = json!({
"parent": config.space
});
let body = json!({
"text": config.text
});
let scopes: Vec<String> = create_method.scopes.iter().map(|s| s.to_string()).collect();
Ok((params.to_string(), body.to_string(), scopes))
}
/// Configuration for sending a chat message.
///
/// This struct holds the parsed arguments for the `+send` command.
/// We use `String` here to own the data, as it will be used to construct
/// the JSON body for the API request.
pub struct SendConfig {
/// The space to send the message to (e.g., "spaces/AAAA...").
pub space: String,
/// The text content of the message.
pub text: String,
}
/// Parses the command line arguments into a `SendConfig` struct.
///
/// # Arguments
///
/// * `matches` - The `ArgMatches` from `clap` containing the parsed arguments.
///
/// # Returns
///
/// * `SendConfig` - The populated configuration struct.
pub fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
let space = matches.get_one::<String>("space").unwrap().clone();
crate::validate::validate_resource_name(&space)?;
Ok(SendConfig {
space,
text: matches.get_one::<String>("text").unwrap().clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestDescription, RestMethod, RestResource};
use std::collections::HashMap;
fn make_mock_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"create".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
let mut messages_res = RestResource::default();
messages_res.methods = methods;
let mut spaces_res = RestResource::default();
spaces_res
.resources
.insert("messages".to_string(), messages_res);
let mut resources = HashMap::new();
resources.insert("spaces".to_string(), spaces_res);
RestDescription {
resources,
..Default::default()
}
}
fn make_matches_send(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("space").long("space"))
.arg(Arg::new("text").long("text"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_send_request() {
let doc = make_mock_doc();
let config = SendConfig {
space: "spaces/123".to_string(),
text: "hello chat".to_string(),
};
let (params, body, scopes) = build_send_request(&config, &doc).unwrap();
assert!(params.contains("spaces/123"));
assert!(body.contains("hello chat"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_parse_send_args() {
let matches = make_matches_send(&["test", "--space", "valid-space", "--text", "t"]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.space, "valid-space");
assert_eq!(config.text, "t");
}
#[test]
fn test_parse_send_args_rejects_traversal_in_space() {
let matches = make_matches_send(&["test", "--space", "../etc/passwd", "--text", "t"]);
let result = parse_send_args(&matches);
assert!(
result.is_err(),
"space with path traversal should be rejected"
);
}
#[test]
fn test_parse_send_args_rejects_query_injection_in_space() {
let matches =
make_matches_send(&["test", "--space", "spaces/AAA?key=injected", "--text", "t"]);
let result = parse_send_args(&matches);
assert!(
result.is_err(),
"space with query characters should be rejected"
);
}
#[test]
fn test_inject_commands() {
let helper = ChatHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+send"));
}
}
@@ -0,0 +1,206 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
pub struct DocsHelper;
impl Helper for DocsHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+write")
.about("[Helper] Append text to a document")
.arg(
Arg::new("document")
.long("document")
.help("Document ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("text")
.long("text")
.help("Text to append (plain text)")
.required(true)
.value_name("TEXT"),
)
.after_help(
"\
EXAMPLES:
gws docs +write --document DOC_ID --text 'Hello, world!'
TIPS:
Text is inserted at the end of the document body.
For rich formatting, use the raw batchUpdate API instead.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+write") {
let (params_str, body_str, scopes) = build_write_request(matches, doc)?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))),
};
// Method: documents.batchUpdate
let documents_res = doc.resources.get("documents").ok_or_else(|| {
GwsError::Discovery("Resource 'documents' not found".to_string())
})?;
let batch_update_method =
documents_res.methods.get("batchUpdate").ok_or_else(|| {
GwsError::Discovery("Method 'documents.batchUpdate' not found".to_string())
})?;
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
batch_update_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn build_write_request(
matches: &ArgMatches,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let document_id = matches.get_one::<String>("document").unwrap();
let text = matches.get_one::<String>("text").unwrap();
let documents_res = doc
.resources
.get("documents")
.ok_or_else(|| GwsError::Discovery("Resource 'documents' not found".to_string()))?;
let batch_update_method = documents_res.methods.get("batchUpdate").ok_or_else(|| {
GwsError::Discovery("Method 'documents.batchUpdate' not found".to_string())
})?;
let params = json!({
"documentId": document_id
});
let body = json!({
"requests": [
{
"insertText": {
"text": text,
"endOfSegmentLocation": {
"segmentId": "" // Empty means body
}
}
}
]
});
let scopes: Vec<String> = batch_update_method
.scopes
.iter()
.map(|s| s.to_string())
.collect();
Ok((params.to_string(), body.to_string(), scopes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestDescription, RestMethod, RestResource};
use std::collections::HashMap;
fn make_mock_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"batchUpdate".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
let mut documents_res = RestResource::default();
documents_res.methods = methods;
let mut resources = HashMap::new();
resources.insert("documents".to_string(), documents_res);
RestDescription {
resources,
..Default::default()
}
}
fn make_matches_write(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("document").long("document"))
.arg(Arg::new("text").long("text"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_write_request() {
let doc = make_mock_doc();
let matches = make_matches_write(&["test", "--document", "123", "--text", "hello world"]);
let (params, body, scopes) = build_write_request(&matches, &doc).unwrap();
assert!(params.contains("123"));
assert!(body.contains("hello world"));
assert!(body.contains("endOfSegmentLocation"));
assert_eq!(scopes[0], "https://scope");
}
}
@@ -0,0 +1,196 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
pub struct DriveHelper;
impl Helper for DriveHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+upload")
.about("[Helper] Upload a file with automatic metadata")
.arg(
Arg::new("file")
.help("Path to file to upload")
.required(true)
.index(1),
)
.arg(
Arg::new("parent")
.long("parent")
.help("Parent folder ID")
.value_name("ID"),
)
.arg(
Arg::new("name")
.long("name")
.help("Target filename (defaults to source filename)")
.value_name("NAME"),
)
.after_help(
"\
EXAMPLES:
gws drive +upload ./report.pdf
gws drive +upload ./report.pdf --parent FOLDER_ID
gws drive +upload ./data.csv --name 'Sales Data.csv'
TIPS:
MIME type is detected automatically.
Filename is inferred from the local path unless --name is given.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+upload") {
let file_path = matches.get_one::<String>("file").unwrap();
let parent_id = matches.get_one::<String>("parent");
let name_arg = matches.get_one::<String>("name");
// Determine filename
let filename = determine_filename(file_path, name_arg.map(|s| s.as_str()))?;
// Find method: files.create
let files_res = doc
.resources
.get("files")
.ok_or_else(|| GwsError::Discovery("Resource 'files' not found".to_string()))?;
let create_method = files_res.methods.get("create").ok_or_else(|| {
GwsError::Discovery("Method 'files.create' not found".to_string())
})?;
// Build metadata
let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str()));
let body_str = metadata.to_string();
let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Drive auth failed: {e}"))),
};
executor::execute_method(
doc,
create_method,
None,
Some(&body_str),
token.as_deref(),
auth_method,
None,
Some(executor::UploadSource::File {
path: file_path,
content_type: None,
}),
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result<String, GwsError> {
if let Some(n) = name_arg {
Ok(n.to_string())
} else {
Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| GwsError::Validation("Invalid file path".to_string()))
}
}
fn build_metadata(filename: &str, parent_id: Option<&str>) -> Value {
let mut metadata = json!({
"name": filename
});
if let Some(parent) = parent_id {
metadata["parents"] = json!([parent]);
}
metadata
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_determine_filename_explicit() {
assert_eq!(
determine_filename("path/to/file.txt", Some("custom.txt")).unwrap(),
"custom.txt"
);
}
#[test]
fn test_determine_filename_from_path() {
assert_eq!(
determine_filename("path/to/file.txt", None).unwrap(),
"file.txt"
);
}
#[test]
fn test_determine_filename_invalid_path() {
assert!(determine_filename("", None).is_err());
assert!(determine_filename("/", None).is_err()); // Root has no filename component usually
}
#[test]
fn test_build_metadata_no_parent() {
let meta = build_metadata("file.txt", None);
assert_eq!(meta["name"], "file.txt");
assert!(meta.get("parents").is_none());
}
#[test]
fn test_build_metadata_with_parent() {
let meta = build_metadata("file.txt", Some("folder123"));
assert_eq!(meta["name"], "file.txt");
assert_eq!(meta["parents"][0], "folder123");
}
}
@@ -0,0 +1,210 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
pub mod renew;
pub mod subscribe;
use renew::handle_renew;
use subscribe::handle_subscribe;
pub(super) use crate::auth;
pub(super) use crate::error::GwsError;
pub(super) use anyhow::Context;
pub(super) use clap::{Arg, ArgAction, ArgMatches, Command};
pub(super) use derive_builder::Builder;
pub(super) use serde_json::{json, Value};
pub(super) use std::future::Future;
pub(super) use std::pin::Pin;
pub struct EventsHelper;
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";
pub(super) const WORKSPACE_EVENTS_SCOPE: &str =
"https://www.googleapis.com/auth/chat.spaces.readonly";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectId(pub String);
impl std::fmt::Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionName(pub String);
impl std::fmt::Display for SubscriptionName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Helper for EventsHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+subscribe")
.about("[Helper] Subscribe to Workspace events and stream them as NDJSON")
.arg(
Arg::new("target")
.long("target")
.help(
"Workspace resource URI (e.g., //chat.googleapis.com/spaces/SPACE_ID)",
)
.value_name("URI"),
)
.arg(
Arg::new("event-types")
.long("event-types")
.help("Comma-separated CloudEvents types to subscribe to")
.value_name("TYPES"),
)
.arg(
Arg::new("project")
.long("project")
.help("GCP project ID for Pub/Sub resources")
.value_name("PROJECT"),
)
.arg(
Arg::new("subscription")
.long("subscription")
.help("Existing Pub/Sub subscription name (skip setup)")
.value_name("NAME"),
)
.arg(
Arg::new("max-messages")
.long("max-messages")
.help("Max messages per pull batch (default: 10)")
.value_name("N")
.default_value("10"),
)
.arg(
Arg::new("poll-interval")
.long("poll-interval")
.help("Seconds between pulls (default: 5)")
.value_name("SECS")
.default_value("5"),
)
.arg(
Arg::new("once")
.long("once")
.help("Pull once and exit")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("cleanup")
.long("cleanup")
.help("Delete created Pub/Sub resources on exit")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-ack")
.long("no-ack")
.help("Don't auto-acknowledge messages")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("output-dir")
.long("output-dir")
.help("Write each event to a separate JSON file in this directory")
.value_name("DIR"),
)
.after_help("\
EXAMPLES:
gws events +subscribe --target '//chat.googleapis.com/spaces/SPACE' --event-types 'google.workspace.chat.message.v1.created' --project my-project
gws events +subscribe --subscription projects/p/subscriptions/my-sub --once
gws events +subscribe ... --cleanup --output-dir ./events
TIPS:
Without --cleanup, Pub/Sub resources persist for reconnection.
Press Ctrl-C to stop gracefully."),
);
cmd = cmd.subcommand(
Command::new("+renew")
.about("[Helper] Renew/reactivate Workspace Events subscriptions")
.arg(
Arg::new("name")
.long("name")
.help("Subscription name to reactivate (e.g., subscriptions/SUB_ID)")
.value_name("NAME"),
)
.arg(
Arg::new("all")
.long("all")
.help("Renew all subscriptions expiring within --within window")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("within")
.long("within")
.help("Time window for --all (e.g., 1h, 30m, 2d)")
.value_name("DURATION")
.default_value("1h"),
)
.after_help(
"\
EXAMPLES:
gws events +renew --name subscriptions/SUB_ID
gws events +renew --all --within 2d
TIPS:
Subscriptions expire if not renewed periodically.
Use --all with a cron job to keep subscriptions alive.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(sub_matches) = matches.subcommand_matches("+subscribe") {
handle_subscribe(doc, sub_matches).await?;
return Ok(true);
}
if let Some(renew_matches) = matches.subcommand_matches("+renew") {
handle_renew(doc, renew_matches).await?;
return Ok(true);
}
Ok(false)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_commands() {
let helper = EventsHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+subscribe"));
assert!(subcommands.contains(&"+renew"));
}
}
@@ -0,0 +1,283 @@
use super::*;
#[derive(Debug, PartialEq)]
pub struct RenewConfig {
pub name: Option<String>,
pub all: bool,
pub within: String,
}
fn parse_renew_args(matches: &ArgMatches) -> Result<RenewConfig, GwsError> {
let name = matches.get_one::<String>("name").cloned();
let all = matches.get_flag("all");
let within = matches
.get_one::<String>("within")
.cloned()
.unwrap_or_else(|| "1h".to_string());
if name.is_none() && !all {
return Err(GwsError::Validation(
"Either --name or --all is required for +renew".to_string(),
));
}
Ok(RenewConfig { name, all, within })
}
/// Handles the `+renew` command.
pub(super) async fn handle_renew(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_renew_args(matches)?;
let dry_run = matches.get_flag("dry-run");
if dry_run {
eprintln!("🏃 DRY RUN — no changes will be made\n");
// Handle dry-run case and exit early
let result = if let Some(name) = config.name {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Reactivating subscription: {name}");
json!({
"dry_run": true,
"action": "Would reactivate subscription",
"name": name,
"note": "Run without --dry-run to actually reactivate the subscription"
})
} else {
json!({
"dry_run": true,
"action": "Would list and renew subscriptions expiring within",
"within": config.within,
"note": "Run without --dry-run to actually renew subscriptions"
})
};
println!(
"{}",
serde_json::to_string_pretty(&result).context("Failed to serialize dry-run output")?
);
return Ok(());
}
// Real run logic
let client = crate::client::build_client()?;
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?;
if let Some(name) = config.name {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Reactivating subscription: {name}");
let resp = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to reactivate subscription")?;
let body: Value = resp.json().await.context("Failed to parse response")?;
println!(
"{}",
serde_json::to_string_pretty(&body).context("Failed to serialize response body")?
);
} else {
let within_secs = parse_duration(&config.within)?;
let resp = client
.get("https://workspaceevents.googleapis.com/v1/subscriptions")
.bearer_auth(&ws_token)
.send()
.await
.context("Failed to list subscriptions")?;
let body: Value = resp.json().await.context("Failed to parse response")?;
let mut renewed = 0;
if let Some(subs) = body.get("subscriptions").and_then(|s| s.as_array()) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let to_renew = filter_subscriptions_to_renew(subs, now, within_secs);
for name in to_renew {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Renewing {name}...");
let _ = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await;
renewed += 1;
}
}
let result = json!({
"status": "success",
"renewed": renewed,
});
println!(
"{}",
serde_json::to_string_pretty(&result).context("Failed to serialize result")?
);
}
Ok(())
}
fn filter_subscriptions_to_renew(subs: &[Value], now_secs: u64, within_secs: u64) -> Vec<String> {
let mut result = Vec::new();
for sub in subs {
if let Some(expire_time) = sub.get("expireTime").and_then(|e| e.as_str()) {
if let Some(expire_secs) = parse_rfc3339_rough(expire_time) {
let remaining = expire_secs.saturating_sub(now_secs);
if remaining < within_secs {
if let Some(name) = sub.get("name").and_then(|n| n.as_str()) {
result.push(name.to_string());
}
}
}
}
}
result
}
/// Parses a duration string like "1h", "30m", "2d" into seconds.
fn parse_duration(s: &str) -> Result<u64, GwsError> {
let s = s.trim();
if s.is_empty() {
return Err(GwsError::Validation("Empty duration".to_string()));
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.map_err(|_| GwsError::Validation(format!("Invalid duration: {s}")))?;
match unit {
"s" => Ok(num),
"m" => Ok(num * 60),
"h" => Ok(num * 3600),
"d" => Ok(num * 86400),
_ => Err(GwsError::Validation(format!(
"Unknown duration unit '{unit}'. Use s, m, h, or d."
))),
}
}
/// Parse an RFC 3339 timestamp to Unix seconds.
fn parse_rfc3339_rough(s: &str) -> Option<u64> {
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.timestamp() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_matches_renew(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("name").long("name"))
.arg(Arg::new("all").long("all").action(ArgAction::SetTrue))
.arg(Arg::new("within").long("within").default_value("1h"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h").unwrap(), 3600);
assert_eq!(parse_duration("2h").unwrap(), 7200);
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("30m").unwrap(), 1800);
}
#[test]
fn test_parse_duration_days() {
assert_eq!(parse_duration("1d").unwrap(), 86400);
assert_eq!(parse_duration("7d").unwrap(), 604800);
}
#[test]
fn test_parse_duration_invalid() {
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
}
#[test]
fn test_parse_rfc3339_rough() {
// 2026-02-13T10:00:00Z
let ts = parse_rfc3339_rough("2026-02-13T10:00:00Z").unwrap();
assert!(ts > 0);
// Check simple calculation logic (not verifying exact epoch seconds against a full library)
// just consistency
let ts2 = parse_rfc3339_rough("2026-02-13T10:00:01Z").unwrap();
assert_eq!(ts2, ts + 1);
}
#[test]
fn test_parse_renew_args_name() {
let matches = make_matches_renew(&["test", "--name", "subs/123"]);
let config = parse_renew_args(&matches).unwrap();
assert_eq!(config.name, Some("subs/123".to_string()));
assert!(!config.all);
}
#[test]
fn test_parse_renew_args_all() {
let matches = make_matches_renew(&["test", "--all", "--within", "2h"]);
let config = parse_renew_args(&matches).unwrap();
assert!(config.name.is_none());
assert!(config.all);
assert_eq!(config.within, "2h");
}
#[test]
fn test_parse_renew_args_missing() {
let matches = make_matches_renew(&["test"]);
assert!(parse_renew_args(&matches).is_err());
}
#[test]
fn test_filter_subscriptions_to_renew() {
// Let's use `parse_rfc3339_rough` to get a baseline
let base_ts = parse_rfc3339_rough("2026-02-13T10:00:00Z").unwrap();
// sub1 expires in 30m (1800s from base)
let sub1 = json!({
"name": "subs/1",
"expireTime": "2026-02-13T10:30:00Z"
});
// sub2 expires in 2h (7200s from base)
let sub2 = json!({
"name": "subs/2",
"expireTime": "2026-02-13T12:00:00Z"
});
let subs = vec![sub1, sub2];
// within 1h (3600s) -> should catch sub1 (1800s < 3600s) but not sub2 (7200s > 3600s)
let to_renew = filter_subscriptions_to_renew(&subs, base_ts, 3600);
assert_eq!(to_renew.len(), 1);
assert_eq!(to_renew[0], "subs/1");
// within 3h (10800s) -> should catch both
let to_renew_all = filter_subscriptions_to_renew(&subs, base_ts, 10800);
assert_eq!(to_renew_all.len(), 2);
}
}
@@ -0,0 +1,945 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::sanitize_for_terminal;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Builder)]
#[builder(setter(into))]
pub struct SubscribeConfig {
#[builder(default)]
target: Option<String>,
#[builder(default)]
event_types: Vec<String>,
#[builder(default)]
project: Option<ProjectId>,
#[builder(default)]
subscription: Option<SubscriptionName>,
#[builder(default = "10")]
max_messages: u32,
#[builder(default = "2")]
poll_interval: u64,
#[builder(default)]
once: bool,
#[builder(default)]
cleanup: bool,
#[builder(default)]
no_ack: bool,
#[builder(default)]
output_dir: Option<PathBuf>,
}
fn parse_subscribe_args(matches: &ArgMatches) -> Result<SubscribeConfig, GwsError> {
let mut builder = SubscribeConfigBuilder::default();
if let Some(target) = matches.get_one::<String>("target") {
builder.target(Some(target.clone()));
}
if let Some(event_types) = matches.get_one::<String>("event-types") {
builder.event_types(
event_types
.split(',')
.map(|t| t.trim().to_string())
.collect::<Vec<_>>(),
);
}
if let Some(project) = matches
.get_one::<String>("project")
.cloned()
.or_else(|| std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok())
{
builder.project(Some(ProjectId(project)));
}
if let Some(subscription) = matches.get_one::<String>("subscription") {
crate::validate::validate_resource_name(subscription)?;
builder.subscription(Some(SubscriptionName(subscription.clone())));
}
if let Some(max_messages) = matches
.get_one::<String>("max-messages")
.and_then(|s| s.parse::<u32>().ok())
{
builder.max_messages(max_messages);
}
if let Some(poll_interval) = matches
.get_one::<String>("poll-interval")
.and_then(|s| s.parse::<u64>().ok())
{
builder.poll_interval(poll_interval);
}
builder.once(matches.get_flag("once"));
builder.cleanup(matches.get_flag("cleanup"));
builder.no_ack(matches.get_flag("no-ack"));
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
builder.output_dir(Some(crate::validate::validate_safe_output_dir(output_dir)?));
}
let config = builder
.build()
.map_err(|e| GwsError::Validation(e.to_string()))?;
validate_subscribe_config(&config)?;
Ok(config)
}
fn validate_subscribe_config(config: &SubscribeConfig) -> Result<(), GwsError> {
if config.subscription.is_none() {
if config.target.is_none() {
return Err(GwsError::Validation(
"--target is required when not using --subscription".to_string(),
));
}
if config.event_types.is_empty() {
return Err(GwsError::Validation(
"--event-types is required when not using --subscription".to_string(),
));
}
if config.project.is_none() {
return Err(GwsError::Validation(
"--project is required when not using --subscription (or set GOOGLE_WORKSPACE_PROJECT_ID)".to_string(),
));
}
}
Ok(())
}
/// Handles the `+subscribe` command.
pub(super) async fn handle_subscribe(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_subscribe_args(matches)?;
let dry_run = matches.get_flag("dry-run");
if dry_run {
eprintln!("🏃 DRY RUN — no changes will be made\n");
}
if let Some(ref dir) = config.output_dir {
if !dry_run {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
}
}
let client = crate::client::build_client()?;
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);
let (pubsub_subscription, topic_name, ws_subscription_name, created_resources) =
if let Some(ref sub_name) = config.subscription {
// Use existing subscription — no setup needed
// (don't fetch Pub/Sub token since we won't need it for existing subscriptions)
if dry_run {
eprintln!("Would listen to existing subscription: {}", sub_name.0);
let result = json!({
"dry_run": true,
"action": "Would listen to existing subscription",
"subscription": sub_name.0,
"note": "Run without --dry-run to actually start listening"
});
println!(
"{}",
serde_json::to_string_pretty(&result)
.context("Failed to serialize dry-run output")?
);
return Ok(());
}
(sub_name.0.clone(), None, None, false)
} else {
// Get Pub/Sub token only when creating new subscription
let pubsub_token = if dry_run {
None
} else {
Some(
auth::get_token(&[PUBSUB_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?,
)
};
// Full setup: create Pub/Sub topic + subscription + Workspace Events subscription
// Validate target before use in both dry-run and actual execution paths
let target = crate::validate::validate_resource_name(&config.target.clone().unwrap())?
.to_string();
let project =
crate::validate::validate_resource_name(&config.project.clone().unwrap().0)?
.to_string();
let event_types_str: Vec<&str> =
config.event_types.iter().map(|s| s.as_str()).collect();
// Generate descriptive names from event types
// e.g. "google.workspace.drive.file.v1.updated" -> "drive-file-updated"
let slug = derive_slug_from_event_types(&event_types_str);
let suffix = format!("{:08x}", rand::random::<u32>());
let topic = format!("projects/{project}/topics/gws-{slug}-{suffix}");
let sub = format!("projects/{project}/subscriptions/gws-{slug}-{suffix}");
// Dry-run: print what would be created and exit
if dry_run {
eprintln!("Would create Pub/Sub topic: {topic}");
eprintln!("Would create Pub/Sub subscription: {sub}");
eprintln!("Would create Workspace Events subscription for target: {target}");
eprintln!(
"Would listen for event types: {}",
config.event_types.join(", ")
);
let result = json!({
"dry_run": true,
"action": "Would create Workspace Events subscription",
"pubsub_topic": topic,
"pubsub_subscription": sub,
"target": target,
"event_types": config.event_types,
"note": "Run without --dry-run to actually create subscription"
});
println!(
"{}",
serde_json::to_string_pretty(&result)
.context("Failed to serialize dry-run output")?
);
return Ok(());
}
// 1. Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {topic}");
let token = pubsub_token.as_ref().ok_or_else(|| {
GwsError::Auth(
"Token unavailable in non-dry-run mode. This indicates a bug.".to_string(),
)
})?;
let resp = client
.put(format!("{PUBSUB_API_BASE}/{topic}"))
.bearer_auth(token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to create topic")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub topic: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// 2. Create Pub/Sub subscription
eprintln!("Creating Pub/Sub subscription: {sub}");
let sub_body = json!({
"topic": topic,
"ackDeadlineSeconds": 60,
});
let resp = client
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(token)
.header("Content-Type", "application/json")
.json(&sub_body)
.send()
.await
.context("Failed to create subscription")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub subscription: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// 3. Create Workspace Events subscription
eprintln!("Creating Workspace Events subscription...");
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
.await
.map_err(|e| {
GwsError::Auth(format!("Failed to get Workspace Events token: {e}"))
})?;
let ws_body = json!({
"targetResource": target,
"eventTypes": config.event_types,
"notificationEndpoint": {
"pubsubTopic": topic,
},
"payloadOptions": {
"includeResource": true,
},
});
let resp = client
.post("https://workspaceevents.googleapis.com/v1/subscriptions")
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.json(&ws_body)
.send()
.await
.context("Failed to create Workspace Events subscription")?;
let resp_body: Value = resp
.json()
.await
.context("Failed to parse subscription response")?;
let ws_sub_name = resp_body
// Direct subscription response
.get("name")
.and_then(|v| v.as_str())
.filter(|s| s.starts_with("subscriptions/"))
.or_else(|| {
// LRO response — check response.name
resp_body
.get("response")
.and_then(|r| r.get("name"))
.and_then(|v| v.as_str())
})
.or_else(|| {
// LRO response — check metadata.subscription
resp_body
.get("metadata")
.and_then(|m| m.get("subscription"))
.and_then(|v| v.as_str())
})
.or_else(|| {
// Fall back to the operation name itself
resp_body.get("name").and_then(|v| v.as_str())
})
.unwrap_or("pending")
.to_string();
eprintln!("Workspace Events subscription: {ws_sub_name}");
eprintln!("Listening for events...\n");
(sub, Some(topic), Some(ws_sub_name), true)
};
// Pull loop
let result = pull_loop(
&client,
&pubsub_token_provider,
&pubsub_subscription,
config.clone(),
PUBSUB_API_BASE,
)
.await;
// On exit, print reconnection info or cleanup
if created_resources {
if config.cleanup {
eprintln!("\nCleaning up Pub/Sub resources...");
// Delete Pub/Sub subscription
if let Ok(pubsub_token) = pubsub_token_provider.access_token().await {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{pubsub_subscription}"))
.bearer_auth(&pubsub_token)
.send()
.await;
// Delete Pub/Sub topic
if let Some(ref topic) = topic_name {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{topic}"))
.bearer_auth(&pubsub_token)
.send()
.await;
}
eprintln!("Cleanup complete.");
} else {
eprintln!("Warning: failed to refresh token for cleanup. Resources may need manual deletion.");
}
} else {
eprintln!("\n--- Reconnection Info ---");
eprintln!(
"To reconnect later:\n gws events +subscribe --subscription {}",
pubsub_subscription
);
if let Some(ref ws_name) = ws_subscription_name {
eprintln!("Workspace Events subscription: {ws_name}");
}
if let Some(ref topic) = topic_name {
eprintln!("Pub/Sub topic: {topic}");
}
eprintln!("Pub/Sub subscription: {pubsub_subscription}");
eprintln!("To clean up manually:");
if let Some(ref topic) = topic_name {
eprintln!(
" gcloud pubsub subscriptions delete {}",
pubsub_subscription
);
eprintln!(" gcloud pubsub topics delete {topic}");
}
}
}
result
}
/// Pulls messages from a Pub/Sub subscription in a loop.
async fn pull_loop(
client: &reqwest::Client,
token_provider: &dyn auth::AccessTokenProvider,
subscription: &str,
config: SubscribeConfig,
pubsub_api_base: &str,
) -> Result<(), GwsError> {
let mut file_counter: u64 = 0;
loop {
let token = token_provider
.access_token()
.await
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?;
let pull_body = json!({
"maxMessages": config.max_messages,
});
let pull_future = client
.post(format!("{pubsub_api_base}/{subscription}:pull"))
.bearer_auth(&token)
.header("Content-Type", "application/json")
.json(&pull_body)
.timeout(std::time::Duration::from_secs(config.poll_interval.max(10)))
.send();
let resp = tokio::select! {
result = pull_future => {
match result {
Ok(r) => r,
Err(e) if e.is_timeout() => continue,
Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()),
}
}
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
return Ok(());
}
};
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Pub/Sub pull failed: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
let pull_response: Value = resp.json().await.context("Failed to parse pull response")?;
let (ack_ids, events) = process_events_pull_response(&pull_response);
for event in events {
let json_str =
serde_json::to_string_pretty(&event).unwrap_or_else(|_| "{}".to_string());
if let Some(ref dir) = config.output_dir {
file_counter += 1;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let path = dir.join(format!("{ts}_{file_counter}.json"));
if let Err(e) = std::fs::write(&path, &json_str) {
eprintln!(
"Warning: failed to write {}: {}",
path.display(),
sanitize_for_terminal(&e.to_string())
);
} else {
eprintln!("Wrote {}", path.display());
}
} else {
println!(
"{}",
serde_json::to_string(&event).unwrap_or_else(|_| "{}".to_string())
);
}
}
// Acknowledge messages
if !config.no_ack && !ack_ids.is_empty() {
let ack_body = json!({
"ackIds": ack_ids,
});
let _ = client
.post(format!("{pubsub_api_base}/{subscription}:acknowledge"))
.bearer_auth(&token)
.header("Content-Type", "application/json")
.json(&ack_body)
.send()
.await;
}
if config.once {
break;
}
// Check for SIGINT/SIGTERM between polls
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
break;
}
}
}
Ok(())
}
fn process_events_pull_response(response: &Value) -> (Vec<String>, Vec<Value>) {
let mut ack_ids = Vec::new();
let mut events = Vec::new();
if let Some(messages) = response.get("receivedMessages").and_then(|m| m.as_array()) {
for msg in messages {
if let Some(ack_id) = msg.get("ackId").and_then(|a| a.as_str()) {
ack_ids.push(ack_id.to_string());
}
if let Some(pubsub_msg) = msg.get("message") {
events.push(decode_cloud_event(pubsub_msg));
}
}
}
(ack_ids, events)
}
/// Decodes a Pub/Sub message containing a CloudEvent.
fn decode_cloud_event(pubsub_msg: &Value) -> Value {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let attributes = pubsub_msg.get("attributes").cloned().unwrap_or(json!({}));
let event_type = attributes
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let source = attributes
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let time = attributes
.get("time")
.and_then(|v| v.as_str())
.unwrap_or("");
// Decode base64 data
let data = pubsub_msg
.get("data")
.and_then(|d| d.as_str())
.and_then(|d| STANDARD.decode(d).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.unwrap_or(json!(null));
json!({
"type": event_type,
"source": source,
"time": time,
"attributes": attributes,
"data": data,
})
}
/// Derives a readable slug from event types for Pub/Sub resource naming.
/// e.g. ["google.workspace.drive.file.v1.updated"] -> "drive-file-updated"
/// Multiple types are joined: ["...drive.file.v1.updated", "...drive.file.v1.created"] -> "drive-file-updated-created"
fn derive_slug_from_event_types(event_types: &[&str]) -> String {
let parts: Vec<String> = event_types
.iter()
.map(|et| {
// Strip "google.workspace." prefix and version segment
let stripped = et.strip_prefix("google.workspace.").unwrap_or(et);
// Split by '.', remove version-like segments (e.g. "v1")
let segments: Vec<&str> = stripped
.split('.')
.filter(|s| {
!s.starts_with('v')
|| s.len() > 3
|| !s[1..].chars().all(|c| c.is_ascii_digit())
})
.collect();
segments.join("-")
})
.collect();
let slug = if parts.len() == 1 {
parts[0].clone()
} else {
// Find common prefix across event types, then append distinct suffixes
let first_segments: Vec<&str> = parts[0].split('-').collect();
let mut common_len = 0;
'outer: for i in 0..first_segments.len() {
for p in &parts[1..] {
let segs: Vec<&str> = p.split('-').collect();
if i >= segs.len() || segs[i] != first_segments[i] {
break 'outer;
}
}
common_len = i + 1;
}
let prefix = first_segments[..common_len].join("-");
let suffixes: Vec<String> = parts
.iter()
.map(|p| {
let segs: Vec<&str> = p.split('-').collect();
segs[common_len..].join("-")
})
.filter(|s| !s.is_empty())
.collect();
if suffixes.is_empty() {
prefix
} else {
format!("{}-{}", prefix, suffixes.join("-"))
}
};
// Truncate to keep Pub/Sub resource names within limits
let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
slug.trim_end_matches('-').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::FakeTokenProvider;
use base64::Engine as _;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn spawn_subscribe_server() -> (
String,
Arc<Mutex<Vec<(String, String)>>>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded_requests = Arc::clone(&requests);
let handle = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0_u8; 8192];
let bytes_read = stream.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..bytes_read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("")
.to_string();
let auth_header = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
.unwrap_or("")
.trim()
.to_string();
recorded_requests
.lock()
.await
.push((path.clone(), auth_header));
let body = match path.as_str() {
"/v1/projects/test/subscriptions/demo:pull" => json!({
"receivedMessages": [{
"ackId": "ack-1",
"message": {
"attributes": {
"type": "google.workspace.chat.message.v1.created",
"source": "//chat/spaces/A"
},
"data": base64::engine::general_purpose::STANDARD
.encode(json!({ "id": "evt-1" }).to_string())
}
}]
})
.to_string(),
"/v1/projects/test/subscriptions/demo:acknowledge" => json!({}).to_string(),
other => panic!("unexpected request path: {other}"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
(format!("http://{addr}/v1"), requests, handle)
}
fn make_matches_subscribe(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("target").long("target"))
.arg(Arg::new("event-types").long("event-types"))
.arg(Arg::new("project").long("project"))
.arg(Arg::new("subscription").long("subscription"))
.arg(Arg::new("max-messages").long("max-messages"))
.arg(Arg::new("poll-interval").long("poll-interval"))
.arg(Arg::new("once").long("once").action(ArgAction::SetTrue))
.arg(
Arg::new("cleanup")
.long("cleanup")
.action(ArgAction::SetTrue),
)
.arg(Arg::new("no-ack").long("no-ack").action(ArgAction::SetTrue))
.arg(Arg::new("output-dir").long("output-dir"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_subscribe_args_invalid_output_dir() {
let matches = make_matches_subscribe(&["test", "--output-dir", "../../etc"]);
let result = parse_subscribe_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_subscribe_args() {
let matches = make_matches_subscribe(&[
"test",
"--target",
"//chat/spaces/A",
"--event-types",
"type1,type2",
"--project",
"my-project",
"--max-messages",
"20",
"--once",
]);
let config = parse_subscribe_args(&matches).unwrap();
assert_eq!(config.target, Some("//chat/spaces/A".to_string()));
assert_eq!(config.event_types, vec!["type1", "type2"]);
assert_eq!(config.project, Some(ProjectId("my-project".to_string())));
assert_eq!(config.max_messages, 20);
assert!(config.once);
assert!(!config.cleanup);
}
#[test]
fn test_parse_subscribe_args_subscription() {
let matches = make_matches_subscribe(&["test", "--subscription", "subs/my-sub"]);
let config = parse_subscribe_args(&matches).unwrap();
assert_eq!(
config.subscription,
Some(SubscriptionName("subs/my-sub".to_string()))
);
// Others defaults
assert_eq!(config.max_messages, 10);
}
#[test]
fn test_slug_single_event_type() {
let types = vec!["google.workspace.drive.file.v1.updated"];
assert_eq!(derive_slug_from_event_types(&types), "drive-file-updated");
}
#[test]
fn test_slug_single_event_type_chat() {
let types = vec!["google.workspace.chat.message.v1.created"];
assert_eq!(derive_slug_from_event_types(&types), "chat-message-created");
}
#[test]
fn test_slug_multiple_event_types_common_prefix() {
let types = vec![
"google.workspace.drive.file.v1.updated",
"google.workspace.drive.file.v1.created",
];
let slug = derive_slug_from_event_types(&types);
assert_eq!(slug, "drive-file-updated-created");
}
#[test]
fn test_slug_non_workspace_prefix() {
let types = vec!["custom.event.type"];
let slug = derive_slug_from_event_types(&types);
assert_eq!(slug, "custom-event-type");
}
#[test]
fn test_slug_truncation() {
// Very long event type should be truncated to 40 chars
let types = vec!["google.workspace.very.long.service.name.with.many.segments.v1.updated"];
let slug = derive_slug_from_event_types(&types);
assert!(slug.len() <= 40);
}
#[test]
fn test_decode_cloud_event() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let data = json!({"foo": "bar"}).to_string();
let encoded = STANDARD.encode(data);
let msg = json!({
"attributes": {
"type": "google.workspace.chat.message.v1.created",
"source": "//chat.googleapis.com/spaces/AAA",
"time": "2026-02-13T10:00:00Z"
},
"data": encoded
});
let event = decode_cloud_event(&msg);
assert_eq!(event["type"], "google.workspace.chat.message.v1.created");
assert_eq!(event["source"], "//chat.googleapis.com/spaces/AAA");
assert_eq!(event["data"]["foo"], "bar");
}
#[test]
fn test_process_events_pull_response() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
// Mock a Pub/Sub response with two messages
let data1 = json!({"id": "1", "content": "hello"}).to_string();
let encoded1 = STANDARD.encode(data1);
let data2 = json!({"id": "2", "content": "world"}).to_string();
let encoded2 = STANDARD.encode(data2);
let response = json!({
"receivedMessages": [
{
"ackId": "ack1",
"message": {
"attributes": {
"type": "google.workspace.chat.message.v1.created",
"source": "//chat/spaces/A"
},
"data": encoded1,
"messageId": "msg1"
}
},
{
"ackId": "ack2",
"message": {
"attributes": {
"type": "google.workspace.drive.file.v1.updated",
"source": "//drive/files/B"
},
"data": encoded2,
"messageId": "msg2"
}
}
]
});
let (ack_ids, events) = process_events_pull_response(&response);
assert_eq!(ack_ids.len(), 2);
assert_eq!(ack_ids[0], "ack1");
assert_eq!(ack_ids[1], "ack2");
assert_eq!(events.len(), 2);
assert_eq!(
events[0]["type"],
"google.workspace.chat.message.v1.created"
);
assert_eq!(events[0]["data"]["id"], "1");
assert_eq!(events[1]["type"], "google.workspace.drive.file.v1.updated");
assert_eq!(events[1]["data"]["id"], "2");
}
#[test]
fn test_process_events_pull_response_empty() {
let response = json!({});
let (ack_ids, events) = process_events_pull_response(&response);
assert!(ack_ids.is_empty());
assert!(events.is_empty());
}
#[test]
fn test_handle_subscribe_validation_missing_target() {
let config = SubscribeConfigBuilder::default()
.event_types(vec!["type1".to_string()])
.project(Some(ProjectId("p1".to_string())))
.build()
.unwrap();
let result = validate_subscribe_config(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("--target is required"));
}
#[test]
fn test_handle_subscribe_validation_missing_events() {
let config = SubscribeConfigBuilder::default()
.target(Some("target1".to_string()))
.project(Some(ProjectId("p1".to_string())))
.build()
.unwrap();
let result = validate_subscribe_config(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("--event-types is required"));
}
#[test]
fn test_handle_subscribe_validation_missing_project() {
let config = SubscribeConfigBuilder::default()
.target(Some("target1".to_string()))
.event_types(vec!["type1".to_string()])
.build()
.unwrap();
let result = validate_subscribe_config(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("--project is required"));
}
#[tokio::test]
async fn test_pull_loop_refreshes_pubsub_token_between_requests() {
let client = reqwest::Client::new();
let token_provider = FakeTokenProvider::new(["pubsub-token"]);
let (pubsub_base, requests, server) = spawn_subscribe_server().await;
let config = SubscribeConfigBuilder::default()
.subscription(Some(SubscriptionName(
"projects/test/subscriptions/demo".to_string(),
)))
.max_messages(1_u32)
.poll_interval(1_u64)
.once(true)
.build()
.unwrap();
pull_loop(
&client,
&token_provider,
"projects/test/subscriptions/demo",
config,
&pubsub_base,
)
.await
.unwrap();
server.await.unwrap();
let requests = requests.lock().await;
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].0, "/v1/projects/test/subscriptions/demo:pull");
assert_eq!(requests[0].1, "authorization: Bearer pubsub-token");
assert_eq!(
requests[1].0,
"/v1/projects/test/subscriptions/demo:acknowledge"
);
assert_eq!(requests[1].1, "authorization: Bearer pubsub-token");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use std::io::{self, Write};
/// Handle the `+read` subcommand.
pub(super) async fn handle_read(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let message_id = matches.get_one::<String>("id").unwrap();
let dry_run = matches.get_flag("dry-run");
let original = if dry_run {
OriginalMessage::dry_run_placeholder(message_id)
} else {
let t = auth::get_token(&[GMAIL_READONLY_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
fetch_message_metadata(&client, &t, message_id).await?
};
let format = matches.get_one::<String>("format").unwrap();
let show_headers = matches.get_flag("headers");
let use_html = matches.get_flag("html");
let mut stdout = io::stdout().lock();
if format == "json" {
let json_output = serde_json::to_string_pretty(&original)
.context("Failed to serialize message to JSON")?;
writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?;
return Ok(());
}
if show_headers {
// Format structured fields into display strings for header output.
let from_str = original.from.to_string();
let to_str = format_mailbox_list(&original.to);
let cc_str = original
.cc
.as_ref()
.map(|cc| format_mailbox_list(cc))
.unwrap_or_default();
let headers_to_show: [(&str, &str); 5] = [
("From", &from_str),
("To", &to_str),
("Cc", &cc_str),
("Subject", &original.subject),
("Date", original.date.as_deref().unwrap_or_default()),
];
for (name, value) in headers_to_show {
if value.is_empty() {
continue;
}
// Replace newlines to prevent header spoofing in the output, then sanitize.
let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
writeln!(stdout, "{}: {}", name, sanitized_value)
.with_context(|| format!("Failed to write '{name}' header"))?;
}
writeln!(stdout, "---").context("Failed to write header separator")?;
}
let body = if use_html {
original
.body_html
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(&original.body_text)
} else {
&original.body_text
};
writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?;
Ok(())
}
/// Format a slice of Mailbox as a displayable comma-separated string.
fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
mailboxes
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(", ")
}
use crate::output::sanitize_for_terminal;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_for_terminal() {
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
let sanitized = sanitize_for_terminal(malicious);
// ANSI escape sequences (control chars) should be removed
assert!(!sanitized.contains('\x1b'));
assert!(!sanitized.contains('\x07'));
// CR is also stripped (can be abused for terminal overwrite attacks)
assert!(!sanitized.contains('\r'));
// Newline and tab should be preserved
assert!(sanitized.contains("Hello"));
assert!(sanitized.contains('\n'));
assert!(sanitized.contains('\t'));
}
#[test]
fn test_format_mailbox_list_empty() {
assert_eq!(format_mailbox_list(&[]), "");
}
#[test]
fn test_format_mailbox_list_single() {
let mailboxes = Mailbox::parse_list("alice@example.com");
let result = format_mailbox_list(&mailboxes);
assert!(result.contains("alice@example.com"));
}
#[test]
fn test_format_mailbox_list_multiple() {
let mailboxes = Mailbox::parse_list("alice@example.com, Bob <bob@example.com>");
let result = format_mailbox_list(&mailboxes);
assert!(result.contains("alice@example.com"));
assert!(result.contains("bob@example.com"));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
/// Handle the `+send` subcommand.
pub(super) async fn handle_send(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let mut config = parse_send_args(matches)?;
let dry_run = matches.get_flag("dry-run");
let token = if dry_run {
None
} else {
// Resolve the target method (send or draft) and use its discovery
// doc scopes, so the token matches the operation. resolve_sender
// gracefully degrades if the token doesn't cover the sendAs.list
// endpoint.
let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?;
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
let t = auth::get_token(&scopes)
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
config.from = resolve_sender(&client, &t, config.from.as_deref()).await?;
Some(t)
};
let raw = create_send_raw_message(&config)?;
super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await
}
pub(super) struct SendConfig {
pub to: Vec<Mailbox>,
pub subject: String,
pub body: String,
pub from: Option<Vec<Mailbox>>,
pub cc: Option<Vec<Mailbox>>,
pub bcc: Option<Vec<Mailbox>>,
pub html: bool,
pub attachments: Vec<Attachment>,
}
fn create_send_raw_message(config: &SendConfig) -> Result<String, GwsError> {
let mb = mail_builder::MessageBuilder::new()
.to(to_mb_address_list(&config.to))
.subject(&config.subject);
let mb = apply_optional_headers(
mb,
config.from.as_deref(),
config.cc.as_deref(),
config.bcc.as_deref(),
);
finalize_message(mb, &config.body, config.html, &config.attachments)
}
fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
let to = Mailbox::parse_list(matches.get_one::<String>("to").unwrap());
if to.is_empty() {
return Err(GwsError::Validation(
"--to must specify at least one recipient".to_string(),
));
}
Ok(SendConfig {
to,
subject: matches.get_one::<String>("subject").unwrap().to_string(),
body: matches.get_one::<String>("body").unwrap().to_string(),
from: parse_optional_mailboxes(matches, "from"),
cc: parse_optional_mailboxes(matches, "cc"),
bcc: parse_optional_mailboxes(matches, "bcc"),
html: matches.get_flag("html"),
attachments: parse_attachments(matches)?,
})
}
#[cfg(test)]
mod tests {
use super::super::tests::{extract_header, strip_qp_soft_breaks};
use super::*;
fn make_matches_send(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("to").long("to"))
.arg(Arg::new("subject").long("subject"))
.arg(Arg::new("body").long("body"))
.arg(Arg::new("from").long("from"))
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
.arg(
Arg::new("attach")
.long("attach")
.short('a')
.action(ArgAction::Append),
)
.arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_send_args() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.to.len(), 1);
assert_eq!(config.to[0].email, "me@example.com");
assert_eq!(config.subject, "Hi");
assert_eq!(config.body, "Body");
assert!(config.from.is_none());
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_send_args_with_from() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--from",
"alias@example.com",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com");
// Whitespace-only --from becomes None
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--from",
" ",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.from.is_none());
}
#[test]
fn test_parse_send_args_with_cc_and_bcc() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--cc",
"carol@example.com",
"--bcc",
"secret@example.com",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com");
assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com");
// Whitespace-only values become None
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--cc",
" ",
"--bcc",
"",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_send_args_html_flag() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"<b>Bold</b>",
"--html",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.html);
// Default is false
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Plain",
]);
let config = parse_send_args(&matches).unwrap();
assert!(!config.html);
}
#[test]
fn test_parse_send_args_empty_to_returns_error() {
let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]);
let err = parse_send_args(&matches).err().unwrap();
assert!(
err.to_string().contains("--to"),
"error should mention --to"
);
}
#[test]
fn test_send_html_raw_message() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "HTML test".to_string(),
body: "<p>Hello <b>world</b></p>".to_string(),
from: None,
cc: None,
bcc: None,
html: true,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
let decoded = strip_qp_soft_breaks(&raw);
assert!(decoded.contains("text/html"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
assert!(extract_header(&raw, "Subject")
.unwrap()
.contains("HTML test"));
assert!(decoded.contains("<p>Hello <b>world</b></p>"));
assert!(extract_header(&raw, "Cc").is_none());
}
#[test]
fn test_send_plain_text_raw_message() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Hello".to_string(),
body: "World".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
assert!(extract_header(&raw, "Subject").unwrap().contains("Hello"));
assert!(raw.contains("text/plain"));
assert!(raw.contains("World"));
}
#[test]
fn test_send_with_cc_and_bcc() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: Some(Mailbox::parse_list("carol@example.com")),
bcc: Some(Mailbox::parse_list("secret@example.com")),
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "To")
.unwrap()
.contains("alice@example.com"));
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("carol@example.com"));
assert!(extract_header(&raw, "Bcc")
.unwrap()
.contains("secret@example.com"));
// Verify no leakage between headers
assert!(!extract_header(&raw, "To")
.unwrap()
.contains("carol@example.com"));
assert!(!extract_header(&raw, "To")
.unwrap()
.contains("secret@example.com"));
}
#[test]
fn test_send_with_from() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: Some(Mailbox::parse_list("alias@example.com")),
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "From")
.unwrap()
.contains("alias@example.com"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
}
#[test]
fn test_send_without_from_has_no_from_header() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "From").is_none());
}
#[test]
fn test_send_multiple_to_recipients() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com, bob@example.com"),
subject: "Group".to_string(),
body: "Hi all".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
let to_header = extract_header(&raw, "To").unwrap();
assert!(to_header.contains("alice@example.com"));
assert!(to_header.contains("bob@example.com"));
}
#[test]
fn test_send_crlf_injection_in_from_does_not_create_header() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: Some(Mailbox::parse_list(
"sender@example.com\r\nBcc: evil@attacker.com",
)),
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
// The CRLF injection should not create a Bcc header
assert!(
extract_header(&raw, "Bcc").is_none(),
"CRLF injection via --from should not create Bcc header"
);
// The From header should contain the sanitized email
assert!(extract_header(&raw, "From")
.unwrap()
.contains("sender@example.com"));
}
#[test]
fn test_send_crlf_injection_in_cc_does_not_create_header() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")),
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
// CRLF stripped → "X-Injected: yes" is concatenated into the email,
// not emitted as a separate header line
assert!(
extract_header(&raw, "X-Injected").is_none(),
"CRLF injection via --cc should not create X-Injected header"
);
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("carol@example.com"));
}
#[test]
fn test_send_with_attachment_produces_multipart() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Report".to_string(),
body: "See attached".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![Attachment {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
content_id: None,
}],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(raw.contains("multipart/mixed"));
assert!(raw.contains("report.pdf"));
assert!(raw.contains("See attached"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("alice@example.com"));
}
}
@@ -0,0 +1,305 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Gmail `+triage` helper — lists unread messages with sender, subject, date.
//!
//! Read-only: fetches unread message metadata (From, Subject, Date) and
//! optionally includes label IDs. Supports custom Gmail search queries
//! via `--query` and configurable result limits via `--max`.
use super::*;
/// Handle the `+triage` subcommand.
pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
let max: u32 = matches
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let query = matches
.get_one::<String>("query")
.map(|s| s.as_str())
.unwrap_or("is:unread");
let show_labels = matches.get_flag("labels");
let output_format = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
// Authenticate — use gmail.readonly instead of gmail.modify because triage
// is read-only and the `q` query parameter is not supported under the
// gmail.metadata scope. When a token carries both metadata and modify
// scopes the API may resolve to the metadata path and reject `q` with 403.
// gmail.readonly always supports `q`.
let token = auth::get_token(&[GMAIL_READONLY_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
// 1. List message IDs
let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages";
let list_resp = client
.get(list_url)
.query(&[("q", query), ("maxResults", &max.to_string())])
.bearer_auth(&token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list messages: {e}")))?;
if !list_resp.status().is_success() {
let err = list_resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 0,
message: err,
reason: "list_failed".to_string(),
enable_url: None,
});
}
let list_json: Value = list_resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse list response: {e}")))?;
let messages = match list_json.get("messages").and_then(|m| m.as_array()) {
Some(m) => m,
None => {
eprintln!("{}", no_messages_msg(query));
return Ok(());
}
};
if messages.is_empty() {
eprintln!("{}", no_messages_msg(query));
return Ok(());
}
// 2. Fetch metadata for each message concurrently
use futures_util::stream::{self, StreamExt};
// Collect message IDs upfront (owned) to avoid lifetime issues in async closures
let msg_ids: Vec<String> = messages
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect();
let results: Vec<Value> = stream::iter(msg_ids)
.map(|msg_id| {
let client = &client;
let token = &token;
async move {
let get_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date",
msg_id
);
let get_resp = crate::client::send_with_retry(|| {
client.get(&get_url).bearer_auth(token)
})
.await
.ok()?;
if !get_resp.status().is_success() {
return None;
}
let msg_json: Value = get_resp.json().await.ok()?;
let headers = msg_json
.get("payload")
.and_then(|p| p.get("headers"))
.and_then(|h| h.as_array());
let mut from = String::new();
let mut subject = String::new();
let mut date = String::new();
if let Some(headers) = headers {
for h in headers {
let name = h.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = h.get("value").and_then(|v| v.as_str()).unwrap_or("");
match name {
"From" => from = value.to_string(),
"Subject" => subject = value.to_string(),
"Date" => date = value.to_string(),
_ => {}
}
}
}
let mut entry = json!({
"id": msg_id,
"from": from,
"subject": subject,
"date": date,
});
if show_labels {
let labels = msg_json
.get("labelIds")
.cloned()
.unwrap_or(Value::Array(vec![]));
entry["labels"] = labels;
}
Some(entry)
}
})
.buffer_unordered(10)
.filter_map(|r| async { r })
.collect()
.await;
// 3. Output
let result_count = results.len();
let output = json!({
"messages": results,
"resultSizeEstimate": list_json.get("resultSizeEstimate").cloned().unwrap_or(json!(result_count)),
"query": query,
});
println!(
"{}",
crate::formatter::format_value(&output, &output_format)
);
Ok(())
}
/// Returns the human-readable "no messages" diagnostic string.
/// Extracted so the test can reference the exact same message without duplication.
fn no_messages_msg(query: &str) -> String {
format!(
"No messages found matching query: {}",
crate::output::sanitize_for_terminal(query)
)
}
#[cfg(test)]
mod tests {
use super::no_messages_msg;
use clap::{Arg, ArgAction, Command};
/// Build a clap command matching the +triage definition so we can
/// unit-test argument parsing without needing a live GmailHelper.
fn triage_cmd() -> Command {
Command::new("triage")
.arg(
Arg::new("max")
.long("max")
.default_value("20")
.value_name("N"),
)
.arg(Arg::new("query").long("query").value_name("QUERY"))
.arg(Arg::new("labels").long("labels").action(ArgAction::SetTrue))
.arg(Arg::new("format").long("format").value_name("FMT"))
}
#[test]
fn defaults_max_to_20_and_query_to_unread() {
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
let max: u32 = m
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let query = m
.get_one::<String>("query")
.map(|s| s.as_str())
.unwrap_or("is:unread");
assert_eq!(max, 20);
assert_eq!(query, "is:unread");
}
#[test]
fn explicit_max_overrides_default() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--max", "5"])
.unwrap();
let max: u32 = m
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
assert_eq!(max, 5);
}
#[test]
fn non_numeric_max_falls_back_to_20() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--max", "abc"])
.unwrap();
let max: u32 = m
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
assert_eq!(max, 20);
}
#[test]
fn custom_query_overrides_default() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--query", "from:boss"])
.unwrap();
let query = m
.get_one::<String>("query")
.map(|s| s.as_str())
.unwrap_or("is:unread");
assert_eq!(query, "from:boss");
}
#[test]
fn labels_flag_defaults_to_false() {
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
assert!(!m.get_flag("labels"));
}
#[test]
fn labels_flag_set_when_passed() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--labels"])
.unwrap();
assert!(m.get_flag("labels"));
}
#[test]
fn format_defaults_to_table_when_absent() {
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
let fmt = m
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
assert!(matches!(fmt, crate::formatter::OutputFormat::Table));
}
#[test]
fn format_json_when_specified() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--format", "json"])
.unwrap();
let fmt = m
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
assert!(matches!(fmt, crate::formatter::OutputFormat::Json));
}
#[test]
fn empty_result_message_is_not_json() {
// Verify that no_messages_msg() produces a human-readable string that
// belongs on stderr, not stdout. If it were valid JSON it could corrupt
// pipe workflows like `gws gmail +triage | jq`.
let msg = no_messages_msg("label:inbox");
assert!(serde_json::from_str::<serde_json::Value>(&msg).is_err());
}
}
@@ -0,0 +1,993 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::colorize;
use crate::output::sanitize_for_terminal;
const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";
/// Handles the `+watch` command — Gmail push notifications via Pub/Sub.
pub(super) async fn handle_watch(
matches: &ArgMatches,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
) -> Result<(), GwsError> {
let config = parse_watch_args(matches)?;
if let Some(ref dir) = config.output_dir {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
}
let client = crate::client::build_client()?;
let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE]);
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);
// Get tokens
let gmail_token = auth::get_token(&[GMAIL_SCOPE])
.await
.context("Failed to get Gmail token")?;
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
.await
.context("Failed to get Pub/Sub token")?;
let (pubsub_subscription, topic_name, created_resources) = if let Some(ref sub_name) =
config.subscription
{
(sub_name.clone(), None, false)
} else {
let project = config
.project.clone()
.or_else(|| std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok())
.ok_or_else(|| {
GwsError::Validation(
"--project is required when not using --subscription (or set GOOGLE_WORKSPACE_PROJECT_ID)".to_string(),
)
})?;
let suffix = format!("{:08x}", rand::random::<u32>());
let topic = if let Some(ref t) = config.topic {
crate::validate::validate_resource_name(t)?.to_string()
} else {
let project = crate::validate::validate_resource_name(&project)?;
let t = format!("projects/{project}/topics/gws-gmail-watch-{suffix}");
// Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {t}");
let resp = client
.put(format!("{PUBSUB_API_BASE}/{t}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to create topic")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub topic: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// Grant Gmail publish permission on the topic
eprintln!("Granting Gmail push permission on topic...");
let iam_body = json!({
"policy": {
"bindings": [{
"role": "roles/pubsub.publisher",
"members": ["serviceAccount:gmail-api-push@system.gserviceaccount.com"]
}]
}
});
let resp = client
.post(format!("{PUBSUB_API_BASE}/{t}:setIamPolicy"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&iam_body)
.send()
.await
.context("Failed to set topic IAM policy")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
eprintln!("Warning: Could not auto-grant Gmail push permission.");
eprintln!("You may need to manually grant publisher access:");
eprintln!(
" gcloud pubsub topics add-iam-policy-binding {} \\",
t.split('/').rfind(|s| !s.is_empty()).unwrap_or(&t)
);
eprintln!(
" --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\"
);
eprintln!(" --role=roles/pubsub.publisher");
eprintln!("Error: {}", sanitize_for_terminal(&body));
}
t
};
let project = crate::validate::validate_resource_name(&project)?;
let sub = format!("projects/{project}/subscriptions/gws-gmail-watch-{suffix}");
// 3. Create Pub/Sub subscription
eprintln!("Creating Pub/Sub subscription: {sub}");
let sub_body = json!({
"topic": topic,
"ackDeadlineSeconds": 60,
});
let resp = client
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&sub_body)
.send()
.await
.context("Failed to create subscription")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub subscription: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// 4. Call gmail.users.watch
eprintln!("Setting up Gmail watch...");
let mut watch_body = json!({
"topicName": topic,
});
if let Some(ref label_ids) = config.label_ids {
let labels: Vec<&str> = label_ids.split(',').map(|s| s.trim()).collect();
watch_body["labelIds"] = json!(labels);
}
let resp = client
.post(format!("{GMAIL_API_BASE}/users/me/watch"))
.bearer_auth(&gmail_token)
.header("Content-Type", "application/json")
.json(&watch_body)
.send()
.await
.context("Failed to call gmail.users.watch")?;
let watch_resp: Value = resp
.json()
.await
.context("Failed to parse watch response")?;
if let Some(err) = watch_resp.get("error") {
return Err(GwsError::Api {
code: err.get("code").and_then(|c| c.as_u64()).unwrap_or(400) as u16,
message: format!(
"gmail.users.watch failed: {}",
serde_json::to_string(err).unwrap_or_default()
),
reason: "gmailError".to_string(),
enable_url: None,
});
}
let history_id = watch_resp
.get("historyId")
.and_then(|h| h.as_str())
.unwrap_or("0");
let expiration = watch_resp
.get("expiration")
.and_then(|e| e.as_str())
.unwrap_or("unknown");
eprintln!("Gmail watch active (historyId: {history_id}, expires: {expiration})");
eprintln!("Listening for new emails...\n");
(sub, Some(topic), true)
};
// Get initial historyId for tracking
let profile_resp = client
.get(format!("{GMAIL_API_BASE}/users/me/profile"))
.bearer_auth(&gmail_token)
.send()
.await
.context("Failed to get Gmail profile")?;
let profile: Value = profile_resp.json().await.unwrap_or(json!({}));
let mut last_history_id: u64 = profile
.get("historyId")
.and_then(|h| h.as_str().or_else(|| h.as_u64().map(|_| "")))
.and_then(|s| s.parse().ok())
.or_else(|| profile.get("historyId").and_then(|h| h.as_u64()))
.unwrap_or(0);
// Pull loop
let runtime = WatchRuntime {
client: &client,
pubsub_token_provider: &pubsub_token_provider,
gmail_token_provider: &gmail_token_provider,
sanitize_config,
pubsub_api_base: PUBSUB_API_BASE,
gmail_api_base: GMAIL_API_BASE,
};
let result = watch_pull_loop(
&runtime,
&pubsub_subscription,
&mut last_history_id,
config.clone(),
)
.await;
// Cleanup or print reconnection info
if created_resources {
if config.cleanup {
eprintln!("\nCleaning up Pub/Sub resources...");
if let Ok(pubsub_token) = pubsub_token_provider.access_token().await {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{}", pubsub_subscription))
.bearer_auth(&pubsub_token)
.send()
.await;
if let Some(ref topic) = topic_name {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{}", topic))
.bearer_auth(&pubsub_token)
.send()
.await;
}
eprintln!("Cleanup complete.");
} else {
eprintln!("Warning: failed to refresh token for cleanup. Resources may need manual deletion.");
}
} else {
eprintln!("\n--- Reconnection Info ---");
eprintln!(
"To reconnect later:\n gws gmail +watch --subscription {}",
pubsub_subscription
);
if let Some(ref topic) = topic_name {
eprintln!("Pub/Sub topic: {}", topic);
}
eprintln!("Pub/Sub subscription: {}", pubsub_subscription);
eprintln!("Note: Gmail watch expires after 7 days. Re-run +watch to renew.");
}
}
result
}
/// Pull loop for Gmail watch — polls Pub/Sub, fetches messages via history API.
async fn watch_pull_loop(
runtime: &WatchRuntime<'_>,
subscription: &str,
last_history_id: &mut u64,
config: WatchConfig,
) -> Result<(), GwsError> {
loop {
let pubsub_token = runtime
.pubsub_token_provider
.access_token()
.await
.context("Failed to get Pub/Sub token")?;
let pull_body = json!({ "maxMessages": config.max_messages });
let pull_future = runtime
.client
.post(format!("{}/{subscription}:pull", runtime.pubsub_api_base))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&pull_body)
.timeout(std::time::Duration::from_secs(config.poll_interval.max(10)))
.send();
let resp = tokio::select! {
result = pull_future => {
match result {
Ok(r) => r,
Err(e) if e.is_timeout() => continue,
Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))),
}
}
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
return Ok(());
}
};
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Pub/Sub pull failed: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
let pull_response: Value = resp.json().await.context("Failed to parse pull response")?;
let (ack_ids, max_history_id) = process_pull_response(&pull_response);
if max_history_id > *last_history_id && *last_history_id > 0 {
// Fetch new messages via history API
fetch_and_output_messages(
runtime.client,
runtime.gmail_token_provider,
*last_history_id,
&config.format,
config.output_dir.as_ref(),
runtime.sanitize_config,
runtime.gmail_api_base,
)
.await?;
}
if max_history_id > *last_history_id {
*last_history_id = max_history_id;
}
// Acknowledge messages
if !ack_ids.is_empty() {
let ack_body = json!({ "ackIds": ack_ids });
let _ = runtime
.client
.post(format!(
"{}/{subscription}:acknowledge",
runtime.pubsub_api_base
))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&ack_body)
.send()
.await;
}
if config.once {
break;
}
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
break;
}
}
}
Ok(())
}
fn process_pull_response(response: &Value) -> (Vec<String>, u64) {
let mut ack_ids = Vec::new();
let mut max_history_id = 0;
if let Some(messages) = response.get("receivedMessages").and_then(|m| m.as_array()) {
for msg in messages {
if let Some(ack_id) = msg.get("ackId").and_then(|a| a.as_str()) {
ack_ids.push(ack_id.to_string());
}
// Extract historyId from the notification
if let Some(pubsub_msg) = msg.get("message") {
let data = pubsub_msg
.get("data")
.and_then(|d| d.as_str())
.and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
if let Some(notification) = data {
let notif_history_id = notification
.get("historyId")
.and_then(|h| h.as_u64().or_else(|| h.as_str()?.parse().ok()))
.unwrap_or(0);
if notif_history_id > max_history_id {
max_history_id = notif_history_id;
}
}
}
}
}
(ack_ids, max_history_id)
}
/// Fetches new messages since `start_history_id` and outputs them as NDJSON.
async fn fetch_and_output_messages(
client: &reqwest::Client,
gmail_token_provider: &dyn auth::AccessTokenProvider,
start_history_id: u64,
msg_format: &str,
output_dir: Option<&std::path::PathBuf>,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
gmail_api_base: &str,
) -> Result<(), GwsError> {
let gmail_token = gmail_token_provider
.access_token()
.await
.context("Failed to get Gmail token")?;
let resp = client
.get(format!("{gmail_api_base}/users/me/history"))
.query(&[
("startHistoryId", &start_history_id.to_string()),
("historyTypes", &"messageAdded".to_string()),
])
.bearer_auth(&gmail_token)
.send()
.await
.context("Failed to get history")?;
let body: Value = resp.json().await.unwrap_or(json!({}));
let msg_ids = extract_message_ids_from_history(&body);
for msg_id in msg_ids {
let msg_url = format!(
"{gmail_api_base}/users/me/messages/{}",
crate::validate::encode_path_segment(&msg_id),
);
let msg_resp = client
.get(&msg_url)
.query(&[("format", msg_format)])
.bearer_auth(&gmail_token)
.send()
.await;
if let Ok(resp) = msg_resp {
if let Ok(mut full_msg) = resp.json::<Value>().await {
// Apply sanitization if configured
if let Some(ref template) = sanitize_config.template {
let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default();
match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await
{
Ok(result) => {
if let Some(sanitized_msg) = apply_sanitization_result(
full_msg,
sanitize_config,
&result,
&msg_id,
) {
full_msg = sanitized_msg;
} else {
continue;
}
}
Err(e) => {
eprintln!(
"{} Model Armor sanitization failed for message {msg_id}: {}",
colorize("warning:", "33"),
sanitize_for_terminal(&e.to_string())
);
}
}
}
let json_str =
serde_json::to_string_pretty(&full_msg).unwrap_or_else(|_| "{}".to_string());
if let Some(dir) = output_dir {
let path = dir.join(format!(
"{}.json",
crate::validate::encode_path_segment(&msg_id)
));
if let Err(e) = std::fs::write(&path, &json_str) {
eprintln!(
"Warning: failed to write {}: {}",
path.display(),
sanitize_for_terminal(&e.to_string())
);
} else {
eprintln!("Wrote {}", path.display());
}
} else {
println!(
"{}",
serde_json::to_string(&full_msg).unwrap_or_else(|_| "{}".to_string())
);
}
}
}
}
Ok(())
}
fn apply_sanitization_result(
mut full_msg: Value,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
result: &crate::helpers::modelarmor::SanitizationResult,
msg_id: &str,
) -> Option<Value> {
if result.filter_match_state == "MATCH_FOUND" {
match sanitize_config.mode {
crate::helpers::modelarmor::SanitizeMode::Block => {
eprintln!(
"{} Message {msg_id} blocked by Model Armor (match found)",
colorize("blocked:", "31")
);
return None;
}
crate::helpers::modelarmor::SanitizeMode::Warn => {
eprintln!(
"{} Model Armor match found in message {msg_id}",
colorize("warning:", "33")
);
full_msg["_sanitization"] = serde_json::json!({
"filterMatchState": result.filter_match_state,
"filterResults": result.filter_results,
});
}
}
}
Some(full_msg)
}
fn extract_message_ids_from_history(history_body: &Value) -> Vec<String> {
let mut seen_ids = std::collections::HashSet::new();
let mut result = Vec::new();
if let Some(history) = history_body.get("history").and_then(|h| h.as_array()) {
for entry in history {
if let Some(added) = entry.get("messagesAdded").and_then(|m| m.as_array()) {
for msg_entry in added {
if let Some(msg_id) = msg_entry
.get("message")
.and_then(|m| m.get("id"))
.and_then(|id| id.as_str())
{
if seen_ids.insert(msg_id.to_string()) {
result.push(msg_id.to_string());
}
}
}
}
}
}
result
}
#[derive(Debug, Clone)]
struct WatchConfig {
project: Option<String>,
subscription: Option<String>,
topic: Option<String>,
label_ids: Option<String>,
max_messages: u32,
poll_interval: u64,
format: String,
once: bool,
cleanup: bool,
output_dir: Option<std::path::PathBuf>,
}
struct WatchRuntime<'a> {
client: &'a reqwest::Client,
pubsub_token_provider: &'a dyn auth::AccessTokenProvider,
gmail_token_provider: &'a dyn auth::AccessTokenProvider,
sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
pubsub_api_base: &'a str,
gmail_api_base: &'a str,
}
fn parse_watch_args(matches: &ArgMatches) -> Result<WatchConfig, GwsError> {
let format_str = matches
.get_one::<String>("msg-format")
.map(|s| s.as_str())
.unwrap_or("full");
// Note: msg-format is already constrained by clap's value_parser
let output_dir = matches
.get_one::<String>("output-dir")
.map(|dir| crate::validate::validate_safe_output_dir(dir))
.transpose()?;
Ok(WatchConfig {
project: matches.get_one::<String>("project").cloned(),
subscription: matches
.get_one::<String>("subscription")
.map(|s| {
crate::validate::validate_resource_name(s)?;
Ok::<_, GwsError>(s.clone())
})
.transpose()?,
topic: matches.get_one::<String>("topic").cloned(),
label_ids: matches.get_one::<String>("label-ids").cloned(),
max_messages: matches
.get_one::<String>("max-messages")
.and_then(|s| s.parse().ok())
.unwrap_or(10),
poll_interval: matches
.get_one::<String>("poll-interval")
.and_then(|s| s.parse().ok())
.unwrap_or(5),
format: format_str.to_string(),
once: matches.get_flag("once"),
cleanup: matches.get_flag("cleanup"),
output_dir,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::FakeTokenProvider;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn spawn_watch_server() -> (
String,
String,
Arc<Mutex<Vec<(String, String)>>>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded_requests = Arc::clone(&requests);
let handle = tokio::spawn(async move {
for _ in 0..4 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0_u8; 8192];
let bytes_read = stream.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..bytes_read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("")
.to_string();
let auth_header = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
.unwrap_or("")
.trim()
.to_string();
recorded_requests
.lock()
.await
.push((path.clone(), auth_header));
let body = match path.as_str() {
"/v1/projects/test/subscriptions/demo:pull" => {
let payload = base64::engine::general_purpose::STANDARD
.encode(json!({ "historyId": 2 }).to_string());
json!({
"receivedMessages": [{
"ackId": "ack-1",
"message": {
"data": payload,
"messageId": "msg-1"
}
}]
})
.to_string()
}
"/gmail/v1/users/me/history?startHistoryId=1&historyTypes=messageAdded" => {
json!({
"history": [{
"messagesAdded": [{
"message": { "id": "msg-1" }
}]
}]
})
.to_string()
}
"/gmail/v1/users/me/messages/msg%2D1?format=full" => {
json!({ "id": "msg-1" }).to_string()
}
"/v1/projects/test/subscriptions/demo:acknowledge" => json!({}).to_string(),
other => panic!("unexpected request path: {other}"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
(
format!("http://{addr}/v1"),
format!("http://{addr}/gmail/v1"),
requests,
handle,
)
}
#[test]
fn test_extract_message_ids_from_history() {
let history = json!({
"history": [
{
"messagesAdded": [
{ "message": { "id": "msg1", "threadId": "t1" } }
]
},
{
"messagesAdded": [
{ "message": { "id": "msg2", "threadId": "t2" } },
{ "message": { "id": "msg1", "threadId": "t1" } } // duplicate
]
}
]
});
let ids = extract_message_ids_from_history(&history);
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"msg1".to_string()));
assert!(ids.contains(&"msg2".to_string()));
}
#[test]
fn test_extract_message_ids_empty() {
let history = json!({});
let ids = extract_message_ids_from_history(&history);
assert!(ids.is_empty());
}
#[test]
fn test_process_pull_response() {
let encoded_data = URL_SAFE
.encode(json!({ "emailAddress": "me@example.com", "historyId": 12345 }).to_string());
let response = json!({
"receivedMessages": [
{
"ackId": "ack1",
"message": {
"data": encoded_data,
"messageId": "msg1"
}
},
{
"ackId": "ack2",
"message": {
"data": URL_SAFE.encode(json!({ "historyId": 100 }).to_string()),
"messageId": "msg2"
}
}
]
});
let (ack_ids, max_history) = process_pull_response(&response);
assert_eq!(ack_ids.len(), 2);
assert!(ack_ids.contains(&"ack1".to_string()));
assert!(ack_ids.contains(&"ack2".to_string()));
assert_eq!(max_history, 12345);
}
fn make_matches_watch(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("project").long("project"))
.arg(Arg::new("subscription").long("subscription"))
.arg(Arg::new("topic").long("topic"))
.arg(Arg::new("label-ids").long("label-ids"))
.arg(Arg::new("max-messages").long("max-messages"))
.arg(Arg::new("poll-interval").long("poll-interval"))
.arg(Arg::new("msg-format").long("msg-format"))
.arg(Arg::new("once").long("once").action(ArgAction::SetTrue))
.arg(
Arg::new("cleanup")
.long("cleanup")
.action(ArgAction::SetTrue),
)
.arg(Arg::new("output-dir").long("output-dir"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_watch_args_invalid_format_rejected_by_clap() {
// msg-format is constrained by clap's value_parser, so invalid values
// are rejected at the clap level before parse_watch_args is called.
// Verify the real command definition rejects bad formats:
let helper = super::super::GmailHelper;
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(Command::new("test"), &doc);
let watch_cmd = cmd
.get_subcommands()
.find(|c| c.get_name() == "+watch")
.unwrap()
.clone();
let result =
watch_cmd.try_get_matches_from(vec!["+watch", "--msg-format", "invalid-format"]);
assert!(result.is_err());
}
#[test]
fn test_parse_watch_args_invalid_output_dir() {
let matches = make_matches_watch(&["test", "--output-dir", "../../etc"]);
let result = parse_watch_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_watch_args_rejects_traversal_subscription() {
let matches = make_matches_watch(&["test", "--subscription", "../../evil"]);
let result = parse_watch_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("path traversal"));
}
#[test]
fn test_parse_watch_args_full() {
let matches = make_matches_watch(&[
"test",
"--project",
"p1",
"--subscription",
"s1",
"--max-messages",
"20",
"--once",
]);
let config = parse_watch_args(&matches).unwrap();
assert_eq!(config.project.unwrap(), "p1");
assert_eq!(config.subscription.unwrap(), "s1");
assert_eq!(config.max_messages, 20);
assert!(config.once);
assert!(!config.cleanup);
// Default check handled by unwrap_or
assert_eq!(config.poll_interval, 5);
assert_eq!(config.format, "full");
assert_eq!(config.label_ids, None);
assert_eq!(config.topic, None);
assert_eq!(config.output_dir, None);
}
#[test]
fn test_parse_watch_args_defaults() {
let matches = make_matches_watch(&["test"]);
let config = parse_watch_args(&matches).unwrap();
assert_eq!(config.project, None);
assert_eq!(config.subscription, None);
assert_eq!(config.max_messages, 10);
assert_eq!(config.poll_interval, 5);
assert_eq!(config.format, "full");
assert!(!config.once);
assert!(!config.cleanup);
}
#[test]
fn test_parse_watch_args_invalid_numbers() {
let matches = make_matches_watch(&[
"test",
"--max-messages",
"not_a_number",
"--poll-interval",
"invalid",
]);
let config = parse_watch_args(&matches).unwrap();
// Should fallback to defaults
assert_eq!(config.max_messages, 10);
assert_eq!(config.poll_interval, 5);
}
#[test]
fn test_apply_sanitization_result_block_mode() {
let msg = json!({ "id": "msg1" });
let config = crate::helpers::modelarmor::SanitizeConfig {
template: Some("projects/x/locations/y/templates/z".to_string()),
mode: crate::helpers::modelarmor::SanitizeMode::Block,
};
let result = crate::helpers::modelarmor::SanitizationResult {
filter_match_state: "MATCH_FOUND".to_string(),
filter_results: json!([]),
invocation_result: "{}".to_string(),
};
let output = apply_sanitization_result(msg, &config, &result, "msg1");
assert!(output.is_none());
}
#[test]
fn test_apply_sanitization_result_warn_mode() {
let msg = json!({ "id": "msg1" });
let config = crate::helpers::modelarmor::SanitizeConfig {
template: Some("projects/x/locations/y/templates/z".to_string()),
mode: crate::helpers::modelarmor::SanitizeMode::Warn,
};
let result = crate::helpers::modelarmor::SanitizationResult {
filter_match_state: "MATCH_FOUND".to_string(),
filter_results: json!([]),
invocation_result: "{}".to_string(),
};
let output = apply_sanitization_result(msg, &config, &result, "msg1").unwrap();
// Warn mode adds the `_sanitization` metadata.
assert!(output.get("_sanitization").is_some());
assert_eq!(output["_sanitization"]["filterMatchState"], "MATCH_FOUND");
}
#[test]
fn test_apply_sanitization_result_no_match() {
let msg = json!({ "id": "msg1" });
let config = crate::helpers::modelarmor::SanitizeConfig {
template: Some("projects/x/locations/y/templates/z".to_string()),
mode: crate::helpers::modelarmor::SanitizeMode::Block,
};
let result = crate::helpers::modelarmor::SanitizationResult {
filter_match_state: "NO_MATCH_FOUND".to_string(),
filter_results: json!([]),
invocation_result: "{}".to_string(),
};
let output = apply_sanitization_result(msg.clone(), &config, &result, "msg1").unwrap();
// If no match found, block mode returns the exact input untouched.
assert_eq!(output, msg);
assert!(output.get("_sanitization").is_none());
}
#[tokio::test]
async fn test_watch_pull_loop_refreshes_tokens_for_each_request() {
let client = reqwest::Client::new();
let pubsub_provider = FakeTokenProvider::new(["pubsub-token"]);
let gmail_provider = FakeTokenProvider::new(["gmail-token"]);
let (pubsub_base, gmail_base, requests, server) = spawn_watch_server().await;
let mut last_history_id = 1;
let config = WatchConfig {
project: None,
subscription: None,
topic: None,
label_ids: None,
max_messages: 10,
poll_interval: 1,
format: "full".to_string(),
once: true,
cleanup: false,
output_dir: None,
};
let sanitize_config = crate::helpers::modelarmor::SanitizeConfig {
template: None,
mode: crate::helpers::modelarmor::SanitizeMode::Warn,
};
let runtime = WatchRuntime {
client: &client,
pubsub_token_provider: &pubsub_provider,
gmail_token_provider: &gmail_provider,
sanitize_config: &sanitize_config,
pubsub_api_base: &pubsub_base,
gmail_api_base: &gmail_base,
};
watch_pull_loop(
&runtime,
"projects/test/subscriptions/demo",
&mut last_history_id,
config,
)
.await
.unwrap();
server.await.unwrap();
let requests = requests.lock().await;
assert_eq!(requests.len(), 4);
assert_eq!(requests[0].0, "/v1/projects/test/subscriptions/demo:pull");
assert_eq!(requests[0].1, "authorization: Bearer pubsub-token");
assert_eq!(
requests[1].0,
"/gmail/v1/users/me/history?startHistoryId=1&historyTypes=messageAdded"
);
assert_eq!(requests[1].1, "authorization: Bearer gmail-token");
assert_eq!(
requests[2].0,
"/gmail/v1/users/me/messages/msg%2D1?format=full"
);
assert_eq!(requests[2].1, "authorization: Bearer gmail-token");
assert_eq!(
requests[3].0,
"/v1/projects/test/subscriptions/demo:acknowledge"
);
assert_eq!(requests[3].1, "authorization: Bearer pubsub-token");
assert_eq!(last_history_id, 2);
}
}
@@ -0,0 +1,128 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::error::GwsError;
use clap::{ArgMatches, Command};
use std::future::Future;
use std::pin::Pin;
pub mod calendar;
pub mod chat;
pub mod docs;
pub mod drive;
pub mod events;
pub mod gmail;
pub mod modelarmor;
pub mod script;
pub mod sheets;
pub mod workflows;
/// Base URL for the Google Cloud Pub/Sub v1 API.
///
/// Shared across `events::subscribe` and `gmail::watch` so the constant
/// is defined in a single place.
pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
/// Returns a future that completes when a shutdown signal is received.
///
/// On Unix this listens for both SIGINT (Ctrl+C) and SIGTERM; on other
/// platforms only SIGINT is handled. Used by long-running pull loops
/// (`gmail::watch`, `events::subscribe`) to exit cleanly under container
/// orchestrators (Kubernetes, Docker, systemd) that send SIGTERM.
///
/// The signal handler is registered once in a background task on first call
/// so it remains active for the lifetime of the process — no gap between
/// loop iterations.
pub(crate) async fn shutdown_signal() {
use std::sync::OnceLock;
use tokio::sync::Notify;
static NOTIFY: OnceLock<std::sync::Arc<Notify>> = OnceLock::new();
let notify = NOTIFY.get_or_init(|| {
let n = std::sync::Arc::new(Notify::new());
let n2 = n.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
res = tokio::signal::ctrl_c() => {
res.expect("failed to listen for SIGINT");
}
Some(_) = sigterm.recv() => {}
}
}
Err(e) => {
eprintln!(
"warning: could not register SIGTERM handler: {e}. \
Listening for Ctrl+C only."
);
tokio::signal::ctrl_c()
.await
.expect("failed to listen for SIGINT");
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.expect("failed to listen for SIGINT");
}
n2.notify_waiters();
});
n
});
notify.notified().await;
}
/// A trait for service-specific CLI helpers that inject custom commands.
pub trait Helper: Send + Sync {
/// Injects subcommands into the service command.
fn inject_commands(&self, cmd: Command, doc: &crate::discovery::RestDescription) -> Command;
/// Attempts to handle a command. Returns Ok(Some(())) if handled,
/// Ok(None) if not handled (should fall back to dynamic dispatch),
/// or Err if handled but failed.
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
sanitize_config: &'a modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>>;
/// If true, only helper commands are shown (discovery-generated commands are suppressed).
fn helper_only(&self) -> bool {
false
}
}
pub fn get_helper(service: &str) -> Option<Box<dyn Helper>> {
match service {
"gmail" => Some(Box::new(gmail::GmailHelper)),
"sheets" => Some(Box::new(sheets::SheetsHelper)),
"docs" => Some(Box::new(docs::DocsHelper)),
"chat" => Some(Box::new(chat::ChatHelper)),
"drive" => Some(Box::new(drive::DriveHelper)),
"calendar" => Some(Box::new(calendar::CalendarHelper)),
"script" | "apps-script" => Some(Box::new(script::ScriptHelper)),
"workspaceevents" => Some(Box::new(events::EventsHelper)),
"modelarmor" => Some(Box::new(modelarmor::ModelArmorHelper)),
"workflow" => Some(Box::new(workflows::WorkflowHelper)),
_ => None,
}
}
@@ -0,0 +1,780 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::discovery::RestDescription;
use crate::error::GwsError;
use anyhow::Context;
use clap::{Arg, ArgMatches, Command};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
/// Result of a Model Armor sanitization check.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SanitizationResult {
/// The overall state of the match (e.g., "MATCH_FOUND", "NO_MATCH_FOUND").
pub filter_match_state: String,
/// Detailed results from specific filters (PI, Jailbreak, etc.).
#[serde(default)]
pub filter_results: serde_json::Value,
/// The final decision based on the policy (e.g., "BLOCK", "ALLOW").
#[serde(default)]
pub invocation_result: String,
}
/// Controls behavior when sanitization finds a match.
#[derive(Debug, Clone, PartialEq)]
pub enum SanitizeMode {
/// Log warning to stderr, annotate output with _sanitization field
Warn,
/// Suppress response output, exit non-zero
Block,
}
/// Configuration for Model Armor sanitization, threaded through the CLI.
#[derive(Debug, Clone)]
pub struct SanitizeConfig {
pub template: Option<String>,
pub mode: SanitizeMode,
}
impl Default for SanitizeConfig {
/// Provides default values for `SanitizeConfig`.
///
/// By default, no template is set (sanitization disabled) and the mode is `Warn`.
fn default() -> Self {
Self {
template: None,
mode: SanitizeMode::Warn,
}
}
}
impl SanitizeMode {
/// Parses a string into a `SanitizeMode`.
///
/// * "block" (case-insensitive) -> `Block`
/// * Any other value -> `Warn` (safe default)
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"block" => SanitizeMode::Block,
_ => SanitizeMode::Warn,
}
}
}
pub struct ModelArmorHelper;
/// Build the regional base URL for Model Armor API.
/// The discovery doc rootUrl (modelarmor.us.rep.googleapis.com) is incorrect —
/// Model Armor requires region-specific endpoints: modelarmor.{region}.rep.googleapis.com
fn regional_base_url(location: &str) -> String {
format!("https://modelarmor.{location}.rep.googleapis.com/v1")
}
/// Extract location from a full template resource name.
/// e.g. "projects/my-project/locations/us-central1/templates/my-template" -> "us-central1"
fn extract_location(resource_name: &str) -> Option<&str> {
let parts: Vec<&str> = resource_name.split('/').collect();
for i in 0..parts.len() {
if parts[i] == "locations" && i + 1 < parts.len() {
return Some(parts[i + 1]);
}
}
None
}
impl Helper for ModelArmorHelper {
fn inject_commands(&self, mut cmd: Command, _doc: &RestDescription) -> Command {
cmd = cmd.subcommand(
Command::new("+sanitize-prompt")
.about("[Helper] Sanitize a user prompt through a Model Armor template")
.arg(
Arg::new("template")
.long("template")
.help("Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE)")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("text")
.long("text")
.help("Text content to sanitize")
.value_name("TEXT"),
)
.arg(
Arg::new("json")
.long("json")
.help("Full JSON request body (overrides --text)")
.value_name("JSON"),
)
.after_help("\
EXAMPLES:
gws modelarmor +sanitize-prompt --template projects/P/locations/L/templates/T --text 'user input'
echo 'prompt' | gws modelarmor +sanitize-prompt --template ...
TIPS:
If neither --text nor --json is given, reads from stdin.
For outbound safety, use +sanitize-response instead."),
);
cmd = cmd.subcommand(
Command::new("+sanitize-response")
.about("[Helper] Sanitize a model response through a Model Armor template")
.arg(
Arg::new("template")
.long("template")
.help("Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE)")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("text")
.long("text")
.help("Text content to sanitize")
.value_name("TEXT"),
)
.arg(
Arg::new("json")
.long("json")
.help("Full JSON request body (overrides --text)")
.value_name("JSON"),
)
.after_help("\
EXAMPLES:
gws modelarmor +sanitize-response --template projects/P/locations/L/templates/T --text 'model output'
model_cmd | gws modelarmor +sanitize-response --template ...
TIPS:
Use for outbound safety (model -> user).
For inbound safety (user -> model), use +sanitize-prompt."),
);
cmd = cmd.subcommand(
Command::new("+create-template")
.about("[Helper] Create a new Model Armor template")
.arg(
Arg::new("project")
.long("project")
.help("GCP project ID")
.required(true)
.value_name("PROJECT"),
)
.arg(
Arg::new("location")
.long("location")
.help("GCP location (e.g. us-central1)")
.required(true)
.value_name("LOCATION"),
)
.arg(
Arg::new("template-id")
.long("template-id")
.help("Template ID to create")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("preset")
.long("preset")
.help("Use a preset template: jailbreak")
.value_name("PRESET")
.value_parser(["jailbreak"]),
)
.arg(
Arg::new("json")
.long("json")
.help("JSON body for the template configuration (overrides --preset)")
.value_name("JSON"),
)
.after_help("\
EXAMPLES:
gws modelarmor +create-template --project P --location us-central1 --template-id my-tmpl --preset jailbreak
gws modelarmor +create-template --project P --location us-central1 --template-id my-tmpl --json '{...}'
TIPS:
Defaults to the jailbreak preset if neither --preset nor --json is given.
Use the resulting template name with +sanitize-prompt and +sanitize-response."),
);
cmd
}
fn helper_only(&self) -> bool {
true
}
fn handle<'a>(
&'a self,
_doc: &'a RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") {
handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData").await?;
return Ok(true);
}
if let Some(sub) = matches.subcommand_matches("+sanitize-response") {
handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData").await?;
return Ok(true);
}
if let Some(sub) = matches.subcommand_matches("+create-template") {
handle_create_template(sub).await?;
return Ok(true);
}
Ok(false)
})
}
}
pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
/// Sanitize text through a Model Armor template and return the result.
/// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE
pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationResult, GwsError> {
let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?;
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
.await
.context("Failed to get auth token for Model Armor")?;
let client = crate::client::build_client()?;
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body(body)
.send()
.await
.context("Model Armor request failed")?;
let status = resp.status();
let resp_text = resp
.text()
.await
.context("Failed to read Model Armor response")?;
if !status.is_success() {
return Err(GwsError::Other(anyhow::anyhow!(
"Model Armor API returned status {status}: {resp_text}"
)));
}
parse_sanitize_response(&resp_text)
}
/// Make a POST request to Model Armor's regional API endpoint.
async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> {
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
.await
.context("Failed to get auth token")?;
let client = crate::client::build_client()?;
let resp = client
.post(url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.await
.context("HTTP request failed")?;
let status = resp.status();
let text = resp.text().await.context("Failed to read response")?;
if !status.is_success() {
return Err(GwsError::Other(anyhow::anyhow!(
"API returned status {status}: {text}"
)));
}
println!("{text}");
Ok(())
}
/// Handle +sanitize-prompt and +sanitize-response
async fn handle_sanitize(
matches: &ArgMatches,
method_name: &str,
data_field: &str,
) -> Result<(), GwsError> {
let template_raw = matches.get_one::<String>("template").unwrap();
let template = crate::validate::validate_resource_name(template_raw)?;
let location = extract_location(template).ok_or_else(|| {
GwsError::Validation(
"Cannot extract location from template name. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
)
})?;
let body = parse_sanitize_args(matches, data_field)?;
let base = regional_base_url(location);
let url = format!("{base}/{template}:{method_name}");
model_armor_post(&url, &body).await
}
#[derive(Debug, PartialEq)]
pub struct CreateTemplateConfig {
pub project: String,
pub location: String,
pub template_id: String,
pub body: String,
}
fn parse_create_template_args(matches: &ArgMatches) -> Result<CreateTemplateConfig, GwsError> {
let project_raw = matches.get_one::<String>("project").unwrap();
let project = crate::validate::validate_resource_name(project_raw)?.to_string();
let location_raw = matches.get_one::<String>("location").unwrap();
let location = crate::validate::validate_resource_name(location_raw)?.to_string();
let template_id_raw = matches.get_one::<String>("template-id").unwrap();
let template_id = crate::validate::validate_resource_name(template_id_raw)?.to_string();
let body = if let Some(json_str) = matches.get_one::<String>("json") {
json_str.clone()
} else {
let preset = matches
.get_one::<String>("preset")
.map(|s| s.as_str())
.unwrap_or("jailbreak");
load_preset_template(preset)?
};
Ok(CreateTemplateConfig {
project,
location,
template_id,
body,
})
}
pub fn build_create_template_url(config: &CreateTemplateConfig) -> String {
let base = regional_base_url(&config.location);
let project = crate::validate::encode_path_segment(&config.project);
let location = crate::validate::encode_path_segment(&config.location);
let parent = format!("projects/{project}/locations/{location}");
format!(
"{base}/{parent}/templates?templateId={}",
crate::validate::encode_path_segment(&config.template_id)
)
}
/// Handle +create-template
async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> {
let config = parse_create_template_args(matches)?;
let url = build_create_template_url(&config);
eprintln!(
"Creating template '{}' with preset: {}",
config.template_id,
matches
.get_one::<String>("preset")
.map(|s| s.as_str())
.unwrap_or("jailbreak")
);
model_armor_post(&url, &config.body).await
}
/// Loads a preset template JSON file from the templates/modelarmor/ directory.
/// Falls back to the embedded template if the file is not found.
fn load_preset_template(name: &str) -> Result<String, GwsError> {
// Try to find templates relative to the executable
let exe_path = std::env::current_exe().ok();
let search_dirs: Vec<std::path::PathBuf> = [
// Relative to current directory
Some(std::path::PathBuf::from("templates/modelarmor")),
// Relative to executable
exe_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.join("../templates/modelarmor")),
exe_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.join("templates/modelarmor")),
]
.into_iter()
.flatten()
.collect();
let filename = format!("{name}.json");
for dir in &search_dirs {
let path = dir.join(&filename);
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read template '{}'", path.display()))?;
eprintln!("Using preset template from: {}", path.display());
return Ok(content);
}
}
// Fallback: embedded preset
eprintln!("Template file not found, using embedded '{}' preset", name);
Ok(include_str!("../../templates/modelarmor/jailbreak.json").to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_config_default() {
let config = SanitizeConfig::default();
assert!(config.template.is_none());
assert_eq!(config.mode, SanitizeMode::Warn);
}
#[test]
fn test_sanitize_config_with_template() {
let config = SanitizeConfig {
template: Some("projects/p/locations/us-central1/templates/t".to_string()),
mode: SanitizeMode::Block,
};
assert_eq!(
config.template.as_deref(),
Some("projects/p/locations/us-central1/templates/t")
);
assert_eq!(config.mode, SanitizeMode::Block);
}
#[test]
fn test_sanitize_mode_from_str_warn() {
assert_eq!(SanitizeMode::from_str("warn"), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("WARN"), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("Warn"), SanitizeMode::Warn);
}
#[test]
fn test_sanitize_mode_from_str_block() {
assert_eq!(SanitizeMode::from_str("block"), SanitizeMode::Block);
assert_eq!(SanitizeMode::from_str("BLOCK"), SanitizeMode::Block);
assert_eq!(SanitizeMode::from_str("Block"), SanitizeMode::Block);
}
#[test]
fn test_sanitize_mode_from_str_unknown_defaults_to_warn() {
assert_eq!(SanitizeMode::from_str(""), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("invalid"), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("stop"), SanitizeMode::Warn);
}
#[test]
fn test_extract_location_valid() {
assert_eq!(
extract_location("projects/my-project/locations/us-central1/templates/my-template"),
Some("us-central1")
);
}
#[test]
fn test_extract_location_different_region() {
assert_eq!(
extract_location("projects/p/locations/europe-west1/templates/t"),
Some("europe-west1")
);
}
#[test]
fn test_extract_location_no_locations() {
assert_eq!(extract_location("projects/my-project/templates/t"), None);
}
#[test]
fn test_extract_location_empty() {
assert_eq!(extract_location(""), None);
}
#[test]
fn test_extract_location_trailing_locations() {
// "locations" at the end with no value after
assert_eq!(extract_location("projects/p/locations"), None);
}
#[test]
fn test_regional_base_url() {
assert_eq!(
regional_base_url("us-central1"),
"https://modelarmor.us-central1.rep.googleapis.com/v1"
);
}
#[test]
fn test_regional_base_url_different_region() {
assert_eq!(
regional_base_url("europe-west1"),
"https://modelarmor.europe-west1.rep.googleapis.com/v1"
);
}
#[test]
fn test_cloud_platform_scope_constant() {
assert_eq!(
CLOUD_PLATFORM_SCOPE,
"https://www.googleapis.com/auth/cloud-platform"
);
}
#[test]
fn test_build_sanitize_request_data() {
let template = "projects/p/locations/us-central1/templates/t";
let (body, _) =
build_sanitize_request_data(template, "some text", "sanitizeUserPrompt").unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["userPromptData"]["text"], "some text");
}
#[test]
fn test_parse_sanitize_response_success() {
let json_resp = json!({
"sanitizationResult": {
"filterMatchState": "MATCH_FOUND",
"filterResults": {},
"invocationResult": "SUCCESS"
}
})
.to_string();
let res = parse_sanitize_response(&json_resp).unwrap();
assert_eq!(res.filter_match_state, "MATCH_FOUND");
}
#[test]
fn test_parse_sanitize_response_missing_field() {
let json_resp = json!({}).to_string();
assert!(parse_sanitize_response(&json_resp).is_err());
}
}
pub fn build_sanitize_request_data(
template: &str,
text: &str,
method: &str,
) -> Result<(String, String), GwsError> {
let location = extract_location(template).ok_or_else(|| {
GwsError::Validation(
"Cannot extract location from --sanitize template. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
)
})?;
let base = regional_base_url(location);
let url = format!("{base}/{template}:{method}");
// Identify data field based on method
let data_field = if method == "sanitizeUserPrompt" {
"userPromptData"
} else {
"modelResponseData"
};
let body = json!({data_field: {"text": text}}).to_string();
Ok((body, url))
}
pub fn parse_sanitize_response(resp_text: &str) -> Result<SanitizationResult, GwsError> {
// Parse the response to extract sanitizationResult
let parsed: serde_json::Value =
serde_json::from_str(resp_text).context("Failed to parse Model Armor response")?;
let result = parsed.get("sanitizationResult").ok_or_else(|| {
GwsError::Other(anyhow::anyhow!(
"No sanitizationResult in Model Armor response"
))
})?;
let res =
serde_json::from_value(result.clone()).context("Failed to parse sanitization result")?;
Ok(res)
}
fn parse_sanitize_args(matches: &ArgMatches, data_field: &str) -> Result<String, GwsError> {
if let Some(json_str) = matches.get_one::<String>("json") {
Ok(json_str.clone())
} else if let Some(text) = matches.get_one::<String>("text") {
let mut body = serde_json::Map::new();
body.insert(data_field.to_string(), json!({"text": text}));
Ok(serde_json::Value::Object(body).to_string())
} else {
// Try to read from stdin, but since we can't easily test stdin in unit tests,
// we might check for TTY or empty stdin.
// For simplicity here, we assume if we reach here without text/json, we try stdin.
// Note: We removed the TTY check to avoid adding 'atty' or 'is-terminal' dependency.
// This means it will block on stdin if no input is provided, which is standard CLI behavior.
let stdin_text =
std::io::read_to_string(std::io::stdin()).context("Failed to read stdin")?;
if stdin_text.trim().is_empty() {
return Err(GwsError::Validation(
"Provide text via --text, --json, or pipe to stdin".to_string(),
));
}
let mut body = serde_json::Map::new();
body.insert(data_field.to_string(), json!({"text": stdin_text.trim()}));
Ok(serde_json::Value::Object(body).to_string())
}
}
#[cfg(test)]
mod parsing_tests {
use super::*;
use clap::{Arg, Command};
fn make_matches(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("json").long("json"))
.arg(Arg::new("text").long("text"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_sanitize_args_json() {
let matches = make_matches(&["test", "--json", "{\"foo\":\"bar\"}"]);
let body = parse_sanitize_args(&matches, "field").unwrap();
assert_eq!(body, "{\"foo\":\"bar\"}");
}
#[test]
fn test_parse_sanitize_args_text() {
let matches = make_matches(&["test", "--text", "hello"]);
let body = parse_sanitize_args(&matches, "field").unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["field"]["text"], "hello");
}
#[test]
fn test_build_create_template_url() {
let config = CreateTemplateConfig {
project: "p".to_string(),
location: "us-central1".to_string(),
template_id: "t".to_string(),
body: "{}".to_string(),
};
let url = build_create_template_url(&config);
// encode_path_segment encodes hyphens ('-' → '%2D')
assert_eq!(
url,
"https://modelarmor.us-central1.rep.googleapis.com/v1/projects/p/locations/us%2Dcentral1/templates?templateId=t"
);
}
fn make_matches_create(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("project").long("project").required(true))
.arg(Arg::new("location").long("location").required(true))
.arg(Arg::new("template-id").long("template-id").required(true))
.arg(Arg::new("json").long("json"))
.arg(Arg::new("preset").long("preset"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_create_template_args_json() {
let matches = make_matches_create(&[
"test",
"--project",
"p",
"--location",
"l",
"--template-id",
"t",
"--json",
"{\"a\":1}",
]);
let config = parse_create_template_args(&matches).unwrap();
assert_eq!(config.project, "p");
assert_eq!(config.location, "l");
assert_eq!(config.template_id, "t");
assert_eq!(config.body, "{\"a\":1}");
}
#[test]
fn test_parse_create_template_args_preset() {
let matches = make_matches_create(&[
"test",
"--project",
"p",
"--location",
"l",
"--template-id",
"t",
"--preset",
"jailbreak",
]);
let config = parse_create_template_args(&matches).unwrap();
assert_eq!(config.project, "p");
assert_eq!(config.location, "l");
assert_eq!(config.template_id, "t");
assert!(config.body.contains("piAndJailbreakFilterSettings"));
}
#[test]
fn test_load_preset_template_fallback() {
// Will test loading the built-in preset template
let content = load_preset_template("jailbreak").unwrap();
assert!(content.contains("piAndJailbreakFilterSettings"));
}
#[test]
fn test_inject_commands() {
let helper = ModelArmorHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+sanitize-prompt"));
assert!(subcommands.contains(&"+sanitize-response"));
assert!(subcommands.contains(&"+create-template"));
}
#[test]
fn test_build_create_template_url_encodes_segments() {
let config = CreateTemplateConfig {
project: "my-project".to_string(),
location: "us-central1".to_string(),
template_id: "my-template".to_string(),
body: "{}".to_string(),
};
let url = build_create_template_url(&config);
assert!(url.contains("projects/my%2Dproject"));
assert!(url.contains("locations/us%2Dcentral1"));
assert!(url.contains("templateId=my%2Dtemplate"));
}
#[test]
fn test_parse_create_template_args_rejects_traversal() {
let matches = make_matches_create(&[
"test",
"--project",
"../etc",
"--location",
"us-central1",
"--template-id",
"t",
"--preset",
"jailbreak",
]);
assert!(parse_create_template_args(&matches).is_err());
}
}
@@ -0,0 +1,290 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use anyhow::Context;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::fs;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
pub struct ScriptHelper;
impl Helper for ScriptHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+push")
.about("[Helper] Upload local files to an Apps Script project")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("dir")
.long("dir")
.help("Directory containing script files (defaults to current dir)")
.value_name("DIR"),
)
.after_help(
"\
EXAMPLES:
gws script +push --script SCRIPT_ID
gws script +push --script SCRIPT_ID --dir ./src
TIPS:
Supports .gs, .js, .html, and appsscript.json files.
Skips hidden files and node_modules automatically.
This replaces ALL files in the project.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+push") {
let script_id = matches.get_one::<String>("script").unwrap();
let dir_path = matches
.get_one::<String>("dir")
.map(|s| s.as_str())
.unwrap_or(".");
let safe_dir = crate::validate::validate_safe_dir_path(dir_path)?;
let mut files = Vec::new();
visit_dirs(&safe_dir, &mut files)?;
if files.is_empty() {
return Err(GwsError::Validation(format!(
"No eligible files found in '{}'",
dir_path
)));
}
// Find method: projects.updateContent
let projects_res = doc.resources.get("projects").ok_or_else(|| {
GwsError::Discovery("Resource 'projects' not found".to_string())
})?;
let update_method = projects_res.methods.get("updateContent").ok_or_else(|| {
GwsError::Discovery("Method 'projects.updateContent' not found".to_string())
})?;
// Build body
let body = json!({
"files": files
});
let body_str = body.to_string();
let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Script auth failed: {e}"))),
};
let params = json!({
"scriptId": script_id
});
let params_str = params.to_string();
executor::execute_method(
doc,
update_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn visit_dirs(dir: &Path, files: &mut Vec<serde_json::Value>) -> Result<(), GwsError> {
if dir.is_dir() {
for entry in fs::read_dir(dir).context("Failed to read dir")? {
let entry = entry.context("Failed to read entry")?;
let path = entry.path();
if path.is_dir() {
visit_dirs(&path, files)?;
} else if let Some(file_obj) = process_file(&path)? {
files.push(file_obj);
}
}
}
Ok(())
}
fn process_file(path: &Path) -> Result<Option<serde_json::Value>, GwsError> {
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
// Skip hidden files, node_modules, .git, etc. (basic filtering)
if filename.starts_with('.') || path.components().any(|c| c.as_os_str() == "node_modules") {
return Ok(None);
}
let (type_val, name_val) = match extension {
"gs" | "js" => (
"SERVER_JS",
filename.trim_end_matches(".js").trim_end_matches(".gs"),
),
"html" => ("HTML", filename.trim_end_matches(".html")),
"json" => {
if filename == "appsscript.json" {
("JSON", "appsscript")
} else {
return Ok(None);
}
}
_ => return Ok(None),
};
let content = fs::read_to_string(path).map_err(|e| {
GwsError::Validation(format!("Failed to read file '{}': {}", path.display(), e))
})?;
Ok(Some(json!({
"name": name_val,
"type": type_val,
"source": content
})))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_process_file_server_js() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("code.gs");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "function foo() {{}}").unwrap();
let result = process_file(&file_path).unwrap().unwrap();
assert_eq!(result["name"], "code");
assert_eq!(result["type"], "SERVER_JS");
assert_eq!(
result["source"].as_str().unwrap().trim(),
"function foo() {}"
);
}
#[test]
fn test_process_file_html() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("index.html");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "<html></html>").unwrap();
let result = process_file(&file_path).unwrap().unwrap();
assert_eq!(result["name"], "index");
assert_eq!(result["type"], "HTML");
}
#[test]
fn test_process_file_appsscript_json() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("appsscript.json");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "{{}}").unwrap();
let result = process_file(&file_path).unwrap().unwrap();
assert_eq!(result["name"], "appsscript");
assert_eq!(result["type"], "JSON");
}
#[test]
fn test_process_file_ignored() {
let dir = tempdir().unwrap();
// Random JSON
let p1 = dir.path().join("other.json");
File::create(&p1).unwrap();
assert!(process_file(&p1).unwrap().is_none());
// Hidden file
let p2 = dir.path().join(".hidden.gs");
File::create(&p2).unwrap();
assert!(process_file(&p2).unwrap().is_none());
// node_modules
let node_modules = dir.path().join("node_modules");
fs::create_dir(&node_modules).unwrap();
let p3 = node_modules.join("dep.gs");
File::create(&p3).unwrap();
assert!(process_file(&p3).unwrap().is_none());
}
#[test]
fn test_visit_dirs() {
let dir = tempdir().unwrap();
// Root file
let f1 = dir.path().join("root.gs");
File::create(&f1).unwrap();
// Subdir file
let sub = dir.path().join("src");
fs::create_dir(&sub).unwrap();
let f2 = sub.join("utils.js");
File::create(&f2).unwrap();
// Ignored file
let f3 = dir.path().join("ignore.txt");
File::create(&f3).unwrap();
let mut files = Vec::new();
visit_dirs(dir.path(), &mut files).unwrap();
assert_eq!(files.len(), 2);
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
assert!(names.contains(&"root"));
assert!(names.contains(&"utils"));
}
}
@@ -0,0 +1,525 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
pub struct SheetsHelper;
impl Helper for SheetsHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+append")
.about("[Helper] Append a row to a spreadsheet")
.arg(
Arg::new("spreadsheet")
.long("spreadsheet")
.help("Spreadsheet ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("values")
.long("values")
.help("Comma-separated values (simple strings)")
.value_name("VALUES"),
)
.arg(
Arg::new("json-values")
.long("json-values")
.help("JSON array of rows, e.g. '[[\"a\",\"b\"],[\"c\",\"d\"]]'")
.value_name("JSON"),
)
.arg(
Arg::new("range")
.long("range")
.help("Target range in A1 notation (e.g. 'Sheet2!A1'). Defaults to 'A1' (first sheet)")
.value_name("RANGE"),
)
.after_help(
r#"EXAMPLES:
gws sheets +append --spreadsheet ID --values 'Alice,100,true'
gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]'
gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100'
TIPS:
Use --values for simple single-row appends.
Use --json-values for bulk multi-row inserts.
Use --range to target a specific sheet tab (default: A1, i.e. first sheet)."#,
),
);
cmd = cmd.subcommand(
Command::new("+read")
.about("[Helper] Read values from a spreadsheet")
.arg(
Arg::new("spreadsheet")
.long("spreadsheet")
.help("Spreadsheet ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("range")
.long("range")
.help("Range to read (e.g. 'Sheet1!A1:B2')")
.required(true)
.value_name("RANGE"),
)
.after_help(
"\
EXAMPLES:
gws sheets +read --spreadsheet ID --range \"Sheet1!A1:D10\"
gws sheets +read --spreadsheet ID --range Sheet1
TIPS:
Read-only — never modifies the spreadsheet.
For advanced options, use the raw values.get API.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+append") {
let config = parse_append_args(matches);
let (params_str, body_str, scopes) = build_append_request(&config, doc)?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Sheets auth failed: {e}"))),
};
let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets' not found".to_string())
})?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let append_method = values_res.methods.get("append").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string())
})?;
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
append_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+read") {
let config = parse_read_args(matches);
let (params_str, scopes) = build_read_request(&config, doc)?;
// Re-find method
let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets' not found".to_string())
})?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let get_method = values_res.methods.get("get").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.get' not found".to_string())
})?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Sheets auth failed: {e}"))),
};
executor::execute_method(
doc,
get_method,
Some(&params_str),
None,
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn build_append_request(
config: &AppendConfig,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let spreadsheets_res = doc
.resources
.get("spreadsheets")
.ok_or_else(|| GwsError::Discovery("Resource 'spreadsheets' not found".to_string()))?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let append_method = values_res.methods.get("append").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string())
})?;
let params = json!({
"spreadsheetId": config.spreadsheet_id,
"range": config.range,
"valueInputOption": "USER_ENTERED"
});
let body = json!({
"values": config.values
});
// Map `&String` scope URLs to owned `String`s for the return value
let scopes: Vec<String> = append_method.scopes.iter().map(|s| s.to_string()).collect();
Ok((params.to_string(), body.to_string(), scopes))
}
fn build_read_request(
config: &ReadConfig,
doc: &crate::discovery::RestDescription,
) -> Result<(String, Vec<String>), GwsError> {
// ... resource lookup omitted for brevity ...
let spreadsheets_res = doc
.resources
.get("spreadsheets")
.ok_or_else(|| GwsError::Discovery("Resource 'spreadsheets' not found".to_string()))?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let get_method = values_res.methods.get("get").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.get' not found".to_string())
})?;
let params = json!({
"spreadsheetId": config.spreadsheet_id,
"range": config.range
});
let scopes: Vec<String> = get_method.scopes.iter().map(|s| s.to_string()).collect();
Ok((params.to_string(), scopes))
}
/// Configuration for appending values to a spreadsheet.
///
/// Holds the parsed arguments for the `+append` subcommand.
pub struct AppendConfig {
/// The ID of the spreadsheet to append to.
pub spreadsheet_id: String,
/// Target range in A1 notation (e.g. "Sheet2!A1"). Defaults to "A1".
pub range: String,
/// The rows to append, where each inner Vec represents one row.
pub values: Vec<Vec<String>>,
}
/// Parses arguments for the `+append` command.
///
/// Supports both `--values` (single row) and `--json-values` (single or multi-row).
pub fn parse_append_args(matches: &ArgMatches) -> AppendConfig {
let values = if let Some(json_str) = matches.get_one::<String>("json-values") {
// Try parsing as array-of-arrays (multi-row) first
if let Ok(parsed) = serde_json::from_str::<Vec<Vec<String>>>(json_str) {
parsed
} else if let Ok(parsed) = serde_json::from_str::<Vec<String>>(json_str) {
// Single flat array — treat as one row
vec![parsed]
} else {
eprintln!(
"Warning: --json-values is not valid JSON; expected an array or array-of-arrays"
);
Vec::new()
}
} else if let Some(values_str) = matches.get_one::<String>("values") {
vec![values_str.split(',').map(|s| s.to_string()).collect()]
} else {
Vec::new()
};
let range = matches
.get_one::<String>("range")
.cloned()
.unwrap_or_else(|| "A1".to_string());
AppendConfig {
spreadsheet_id: matches.get_one::<String>("spreadsheet").unwrap().clone(),
range,
values,
}
}
/// Configuration for reading values from a spreadsheet.
pub struct ReadConfig {
pub spreadsheet_id: String,
/// A1 notation range (e.g. "Sheet1!A1:B2").
pub range: String,
}
pub fn parse_read_args(matches: &ArgMatches) -> ReadConfig {
ReadConfig {
spreadsheet_id: matches.get_one::<String>("spreadsheet").unwrap().clone(),
range: matches.get_one::<String>("range").unwrap().clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestDescription, RestMethod, RestResource};
use std::collections::HashMap;
fn make_mock_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"append".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
methods.insert(
"get".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
let mut values_res = RestResource::default();
values_res.methods = methods;
let mut spreadsheets_res = RestResource::default();
spreadsheets_res
.resources
.insert("values".to_string(), values_res);
let mut resources = HashMap::new();
resources.insert("spreadsheets".to_string(), spreadsheets_res);
RestDescription {
resources,
..Default::default()
}
}
fn make_matches_append(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("spreadsheet").long("spreadsheet"))
.arg(Arg::new("values").long("values"))
.arg(Arg::new("json-values").long("json-values"))
.arg(Arg::new("range").long("range"));
cmd.try_get_matches_from(args).unwrap()
}
fn make_matches_read(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("spreadsheet").long("spreadsheet"))
.arg(Arg::new("range").long("range"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_append_request() {
let doc = make_mock_doc();
let config = AppendConfig {
spreadsheet_id: "123".to_string(),
range: "A1".to_string(),
values: vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]],
};
let (params, body, scopes) = build_append_request(&config, &doc).unwrap();
assert!(params.contains("123"));
assert!(params.contains("USER_ENTERED"));
assert!(params.contains("A1"));
assert!(body.contains("a"));
assert!(body.contains("b"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_build_append_request_with_range() {
let doc = make_mock_doc();
let config = AppendConfig {
spreadsheet_id: "123".to_string(),
range: "Sheet2!A1".to_string(),
values: vec![vec!["x".to_string()]],
};
let (params, _body, _scopes) = build_append_request(&config, &doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&params).unwrap();
assert_eq!(parsed["range"], "Sheet2!A1");
}
#[test]
fn test_build_read_request() {
let doc = make_mock_doc();
let config = ReadConfig {
spreadsheet_id: "123".to_string(),
range: "A1:B2".to_string(),
};
let (params, scopes) = build_read_request(&config, &doc).unwrap();
assert!(params.contains("123"));
assert!(params.contains("A1:B2"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_parse_append_args_values() {
let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a,b,c"]);
let config = parse_append_args(&matches);
assert_eq!(config.spreadsheet_id, "123");
assert_eq!(config.range, "A1");
assert_eq!(config.values, vec![vec!["a", "b", "c"]]);
}
#[test]
fn test_parse_append_args_with_range() {
let matches = make_matches_append(&[
"test",
"--spreadsheet",
"123",
"--range",
"Sheet2!A1",
"--values",
"a,b",
]);
let config = parse_append_args(&matches);
assert_eq!(config.range, "Sheet2!A1");
}
#[test]
fn test_parse_append_args_default_range() {
let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a"]);
let config = parse_append_args(&matches);
assert_eq!(config.range, "A1");
}
#[test]
fn test_parse_append_args_json_single_row() {
let matches = make_matches_append(&[
"test",
"--spreadsheet",
"123",
"--json-values",
r#"["a","b","c"]"#,
]);
let config = parse_append_args(&matches);
assert_eq!(config.values, vec![vec!["a", "b", "c"]]);
}
#[test]
fn test_parse_append_args_json_multi_row() {
let matches = make_matches_append(&[
"test",
"--spreadsheet",
"123",
"--json-values",
r#"[["Alice","100"],["Bob","200"]]"#,
]);
let config = parse_append_args(&matches);
assert_eq!(
config.values,
vec![vec!["Alice", "100"], vec!["Bob", "200"]]
);
}
#[test]
fn test_build_append_request_multi_row() {
let doc = make_mock_doc();
let config = AppendConfig {
spreadsheet_id: "123".to_string(),
range: "A1".to_string(),
values: vec![
vec!["Alice".to_string(), "100".to_string()],
vec!["Bob".to_string(), "200".to_string()],
],
};
let (_params, body, _scopes) = build_append_request(&config, &doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let values = parsed["values"].as_array().unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values[0], json!(["Alice", "100"]));
assert_eq!(values[1], json!(["Bob", "200"]));
}
#[test]
fn test_parse_read_args() {
let matches = make_matches_read(&["test", "--spreadsheet", "123", "--range", "A1:B2"]);
let config = parse_read_args(&matches);
assert_eq!(config.spreadsheet_id, "123");
assert_eq!(config.range, "A1:B2");
}
#[test]
fn test_inject_commands() {
let helper = SheetsHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+append"));
assert!(subcommands.contains(&"+read"));
}
}
@@ -0,0 +1,777 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Cross-service workflow helpers that compose multiple Google Workspace API
//! calls into high-level productivity actions.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
use std::pin::Pin;
pub struct WorkflowHelper;
impl Helper for WorkflowHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(build_standup_report_cmd());
cmd = cmd.subcommand(build_meeting_prep_cmd());
cmd = cmd.subcommand(build_email_to_task_cmd());
cmd = cmd.subcommand(build_weekly_digest_cmd());
cmd = cmd.subcommand(build_file_announce_cmd());
cmd
}
fn handle<'a>(
&'a self,
_doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(m) = matches.subcommand_matches("+standup-report") {
handle_standup_report(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+meeting-prep") {
handle_meeting_prep(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+email-to-task") {
handle_email_to_task(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+weekly-digest") {
handle_weekly_digest(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+file-announce") {
handle_file_announce(m).await?;
return Ok(true);
}
Ok(false)
})
}
fn helper_only(&self) -> bool {
true
}
}
// ---------------------------------------------------------------------------
// Command definitions
// ---------------------------------------------------------------------------
fn build_standup_report_cmd() -> Command {
Command::new("+standup-report")
.about("[Helper] Today's meetings + open tasks as a standup summary")
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +standup-report
gws workflow +standup-report --format table
TIPS:
Read-only — never modifies data.
Combines calendar agenda (today) with tasks list.",
)
}
fn build_meeting_prep_cmd() -> Command {
Command::new("+meeting-prep")
.about("[Helper] Prepare for your next meeting: agenda, attendees, and linked docs")
.arg(
Arg::new("calendar")
.long("calendar")
.help("Calendar ID (default: primary)")
.default_value("primary")
.value_name("ID"),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +meeting-prep
gws workflow +meeting-prep --calendar Work
TIPS:
Read-only — never modifies data.
Shows the next upcoming event with attendees and description.",
)
}
fn build_email_to_task_cmd() -> Command {
Command::new("+email-to-task")
.about("[Helper] Convert a Gmail message into a Google Tasks entry")
.arg(
Arg::new("message-id")
.long("message-id")
.help("Gmail message ID to convert")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("tasklist")
.long("tasklist")
.help("Task list ID (default: @default)")
.default_value("@default")
.value_name("ID"),
)
.after_help(
"\
EXAMPLES:
gws workflow +email-to-task --message-id MSG_ID
gws workflow +email-to-task --message-id MSG_ID --tasklist LIST_ID
TIPS:
Reads the email subject as the task title and snippet as notes.
Creates a new task — confirm with the user before executing.",
)
}
fn build_weekly_digest_cmd() -> Command {
Command::new("+weekly-digest")
.about("[Helper] Weekly summary: this week's meetings + unread email count")
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +weekly-digest
gws workflow +weekly-digest --format table
TIPS:
Read-only — never modifies data.
Combines calendar agenda (week) with gmail triage summary.",
)
}
fn build_file_announce_cmd() -> Command {
Command::new("+file-announce")
.about("[Helper] Announce a Drive file in a Chat space")
.arg(
Arg::new("file-id")
.long("file-id")
.help("Drive file ID to announce")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("space")
.long("space")
.help("Chat space name (e.g. spaces/SPACE_ID)")
.required(true)
.value_name("SPACE"),
)
.arg(
Arg::new("message")
.long("message")
.help("Custom announcement message")
.value_name("TEXT"),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +file-announce --file-id FILE_ID --space spaces/ABC123
gws workflow +file-announce --file-id FILE_ID --space spaces/ABC123 --message 'Check this out!'
TIPS:
This is a write command — sends a Chat message.
Use `gws drive +upload` first to upload the file, then announce it here.
Fetches the file name from Drive to build the announcement.",
)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn get_json(
client: &reqwest::Client,
url: &str,
token: &str,
query: &[(&str, &str)],
) -> Result<Value, GwsError> {
let resp = client
.get(url)
.query(query)
.bearer_auth(token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("HTTP request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "workflow_request_failed".to_string(),
enable_url: None,
});
}
resp.json::<Value>()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("JSON parse failed: {e}")))
}
fn format_and_print(value: &Value, matches: &ArgMatches) {
let fmt = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or_default();
println!("{}", crate::formatter::format_value(value, &fmt));
}
async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly";
let token = auth::get_token(&[cal_scope, tasks_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
// Resolve account timezone for day boundaries
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;
let today_end_tz = today_start_tz + chrono::Duration::days(1);
let time_min = today_start_tz.to_rfc3339();
let time_max = today_end_tz.to_rfc3339();
// Fetch today's events
let events_json = get_json(
&client,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
&token,
&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "25"),
],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch calendar events: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let events = events_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let meetings: Vec<Value> = events
.iter()
.map(|e| {
json!({
"summary": e.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
"start": e.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
"end": e.get("end").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
// Fetch open tasks
let tasks_json = get_json(
&client,
"https://tasks.googleapis.com/tasks/v1/lists/@default/tasks",
&token,
&[("showCompleted", "false"), ("maxResults", "20")],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch tasks: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let tasks = tasks_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let open_tasks: Vec<Value> = tasks
.iter()
.map(|t| {
json!({
"title": t.get("title").and_then(|v| v.as_str()).unwrap_or(""),
"due": t.get("due").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = json!({
"meetings": meetings,
"meetingCount": meetings.len(),
"tasks": open_tasks,
"taskCount": open_tasks.len(),
"date": now_in_tz.format("%Y-%m-%d").to_string(),
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let token = auth::get_token(&[cal_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
let calendar_id = matches
.get_one::<String>("calendar")
.map(|s| s.as_str())
.unwrap_or("primary");
// Use account timezone for current time
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339();
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
crate::validate::encode_path_segment(calendar_id),
);
let events_json = get_json(
&client,
&events_url,
&token,
&[
("timeMin", now_rfc.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "1"),
],
)
.await?;
let items = events_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
if items.is_empty() {
let output = json!({ "message": "No upcoming meetings found." });
format_and_print(&output, matches);
return Ok(());
}
let event = &items[0];
let attendees = event
.get("attendees")
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default();
let attendee_list: Vec<Value> = attendees
.iter()
.map(|a| {
json!({
"email": a.get("email").and_then(|v| v.as_str()).unwrap_or(""),
"responseStatus": a.get("responseStatus").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = json!({
"summary": event.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
"start": event.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
"end": event.get("end").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
"description": event.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"location": event.get("location").and_then(|v| v.as_str()).unwrap_or(""),
"hangoutLink": event.get("hangoutLink").and_then(|v| v.as_str()).unwrap_or(""),
"htmlLink": event.get("htmlLink").and_then(|v| v.as_str()).unwrap_or(""),
"attendees": attendee_list,
"attendeeCount": attendee_list.len(),
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
let tasks_scope = "https://www.googleapis.com/auth/tasks";
let token = auth::get_token(&[gmail_scope, tasks_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
let message_id = matches.get_one::<String>("message-id").unwrap();
let tasklist = matches
.get_one::<String>("tasklist")
.map(|s| s.as_str())
.unwrap_or("@default");
// 1. Fetch the email
let msg_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
crate::validate::encode_path_segment(message_id),
);
let msg_json = get_json(
&client,
&msg_url,
&token,
&[("format", "metadata"), ("metadataHeaders", "Subject")],
)
.await?;
let subject = msg_json
.get("payload")
.and_then(|p| p.get("headers"))
.and_then(|h| h.as_array())
.and_then(|headers| {
headers.iter().find(|h| {
h.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.eq_ignore_ascii_case("Subject"))
})
})
.and_then(|h| h.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("(No subject)");
let snippet = msg_json
.get("snippet")
.and_then(|v| v.as_str())
.unwrap_or("");
// 2. Create the task
let task_body = json!({
"title": subject,
"notes": format!("From email: {}\n\n{}", message_id, snippet),
});
let tasklist = crate::validate::validate_resource_name(tasklist)?;
let task_url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks",
tasklist,
);
let resp = client
.post(&task_url)
.bearer_auth(&token)
.json(&task_body)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to create task: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "task_create_failed".to_string(),
enable_url: None,
});
}
let task_result: Value = resp.json().await.unwrap_or(json!({}));
let output = json!({
"created": true,
"taskId": task_result.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"title": subject,
"sourceMessageId": message_id,
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
let token = auth::get_token(&[cal_scope, gmail_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
// Resolve account timezone for week boundaries
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let week_end = now_in_tz + chrono::Duration::days(7);
let time_min = now_in_tz.to_rfc3339();
let time_max = week_end.to_rfc3339();
// Fetch this week's events
let events_json = get_json(
&client,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
&token,
&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "50"),
],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch calendar events: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let events = events_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let meetings: Vec<Value> = events
.iter()
.map(|e| {
json!({
"summary": e.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
"start": e.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
// Fetch unread email count
let gmail_json = get_json(
&client,
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
&token,
&[("q", "is:unread"), ("maxResults", "1")],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch unread email count: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let unread_estimate = gmail_json
.get("resultSizeEstimate")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output = json!({
"meetings": meetings,
"meetingCount": meetings.len(),
"unreadEmails": unread_estimate,
"periodStart": time_min,
"periodEnd": time_max,
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
let drive_scope = "https://www.googleapis.com/auth/drive.readonly";
let chat_scope = "https://www.googleapis.com/auth/chat.messages.create";
let token = auth::get_token(&[drive_scope, chat_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
let file_id = matches.get_one::<String>("file-id").unwrap();
let space = matches.get_one::<String>("space").unwrap();
let custom_msg = matches.get_one::<String>("message");
// 1. Fetch file metadata from Drive
let file_url = format!(
"https://www.googleapis.com/drive/v3/files/{}",
crate::validate::encode_path_segment(file_id),
);
let file_json = get_json(
&client,
&file_url,
&token,
&[("fields", "id,name,webViewLink")],
)
.await?;
let file_name = file_json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("file");
let default_link = format!("https://drive.google.com/file/d/{}/view", file_id);
let file_link = file_json
.get("webViewLink")
.and_then(|v| v.as_str())
.unwrap_or(&default_link);
// 2. Send Chat message
let msg_text = custom_msg
.map(|m| format!("{m}\n{file_link}"))
.unwrap_or_else(|| format!("📎 {file_name}\n{file_link}"));
let chat_body = json!({ "text": msg_text });
let space = crate::validate::validate_resource_name(space)?;
let chat_url = format!("https://chat.googleapis.com/v1/{}/messages", space);
let chat_resp = client
.post(&chat_url)
.bearer_auth(&token)
.json(&chat_body)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Chat send failed: {e}")))?;
if !chat_resp.status().is_success() {
let status = chat_resp.status();
let body = chat_resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "chat_send_failed".to_string(),
enable_url: None,
});
}
let output = json!({
"announced": true,
"fileName": file_name,
"fileLink": file_link,
"space": space,
});
format_and_print(&output, matches);
Ok(())
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
// (epoch_to_rfc3339 removed — replaced by account timezone resolution)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_commands() {
let helper = WorkflowHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let names: Vec<_> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
assert!(names.contains(&"+standup-report".to_string()));
assert!(names.contains(&"+meeting-prep".to_string()));
assert!(names.contains(&"+email-to-task".to_string()));
assert!(names.contains(&"+weekly-digest".to_string()));
assert!(names.contains(&"+file-announce".to_string()));
}
#[test]
fn test_helper_only() {
assert!(WorkflowHelper.helper_only());
}
// (test_epoch_to_rfc3339 removed — function replaced by timezone resolution)
#[test]
fn test_build_standup_report_cmd() {
let cmd = build_standup_report_cmd();
assert_eq!(cmd.get_name(), "+standup-report");
}
#[test]
fn test_build_meeting_prep_cmd() {
let cmd = build_meeting_prep_cmd();
assert_eq!(cmd.get_name(), "+meeting-prep");
}
#[test]
fn test_build_email_to_task_cmd() {
let cmd = build_email_to_task_cmd();
assert_eq!(cmd.get_name(), "+email-to-task");
// message-id is required
let args = cmd
.clone()
.try_get_matches_from(vec!["+email-to-task", "--message-id", "123"]);
assert!(args.is_ok());
let args_err = cmd.try_get_matches_from(vec!["+email-to-task"]);
assert!(args_err.is_err());
}
#[test]
fn test_build_weekly_digest_cmd() {
let cmd = build_weekly_digest_cmd();
assert_eq!(cmd.get_name(), "+weekly-digest");
}
#[test]
fn test_build_file_announce_cmd() {
let cmd = build_file_announce_cmd();
assert_eq!(cmd.get_name(), "+file-announce");
let args = cmd.clone().try_get_matches_from(vec![
"+file-announce",
"--file-id",
"123",
"--space",
"spaces/test",
]);
assert!(args.is_ok());
let args_err = cmd.try_get_matches_from(vec!["+file-announce"]);
assert!(args_err.is_err());
}
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Structured Logging
//!
//! Provides opt-in, PII-free logging for HTTP requests and CLI operations.
//! All output goes to stderr or a log file — stdout remains clean for
//! machine-consumable JSON output.
//!
//! ## Environment Variables
//!
//! - `GOOGLE_WORKSPACE_CLI_LOG`: Filter directive for stderr logging
//! (e.g., `gws=debug`, `gws=trace`). If unset, no stderr logging.
//!
//! - `GOOGLE_WORKSPACE_CLI_LOG_FILE`: Directory path for JSON-line log
//! files with daily rotation. If unset, no file logging.
use tracing_subscriber::prelude::*;
/// Environment variable controlling stderr log output.
const ENV_LOG: &str = "GOOGLE_WORKSPACE_CLI_LOG";
/// Environment variable controlling file log output.
const ENV_LOG_FILE: &str = "GOOGLE_WORKSPACE_CLI_LOG_FILE";
/// Initialize the tracing subscriber based on environment variables.
///
/// If neither `GOOGLE_WORKSPACE_CLI_LOG` nor `GOOGLE_WORKSPACE_CLI_LOG_FILE`
/// is set, this is a no-op and logging adds zero overhead.
///
/// This function must be called at most once (typically in `main()`).
/// Subsequent calls will silently fail (tracing only allows one global
/// subscriber).
pub fn init_logging() {
let stderr_filter = std::env::var(ENV_LOG).ok();
let log_file_dir = std::env::var(ENV_LOG_FILE).ok();
// If neither env var is set, skip initialization entirely for zero overhead.
if stderr_filter.is_none() && log_file_dir.is_none() {
return;
}
let registry = tracing_subscriber::registry();
// Stderr layer: human-readable, filtered by GOOGLE_WORKSPACE_CLI_LOG
let stderr_layer = stderr_filter.map(|filter| {
let env_filter = tracing_subscriber::EnvFilter::new(filter);
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_target(false)
.compact()
.with_filter(env_filter)
});
// File layer: JSON-line output with daily rotation
let (file_layer, _guard) = if let Some(ref dir) = log_file_dir {
let file_appender = tracing_appender::rolling::daily(dir, "gws.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(non_blocking)
.with_target(true)
.with_filter(tracing_subscriber::EnvFilter::new("gws=debug"));
(Some(layer), Some(guard))
} else {
(None, None)
};
// Compose layers and set as global subscriber.
// The guard is leaked intentionally so the non-blocking writer stays
// alive for the lifetime of the process.
let subscriber = registry.with(stderr_layer).with(file_layer);
if tracing::subscriber::set_global_default(subscriber).is_ok() {
if let Some(guard) = _guard {
// Leak the guard so the non-blocking writer lives for the process lifetime.
// This is the recommended pattern from tracing-appender docs.
std::mem::forget(guard);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_logging_default_no_panic() {
// With no env vars set, init_logging should be a no-op and not panic.
// We can't truly test the global subscriber in unit tests (it's global state),
// but we can verify the early-return path doesn't panic.
std::env::remove_var(ENV_LOG);
std::env::remove_var(ENV_LOG_FILE);
init_logging();
}
#[test]
fn test_env_var_names() {
assert_eq!(ENV_LOG, "GOOGLE_WORKSPACE_CLI_LOG");
assert_eq!(ENV_LOG_FILE, "GOOGLE_WORKSPACE_CLI_LOG_FILE");
}
}
+738
View File
@@ -0,0 +1,738 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Google Workspace CLI (gws)
//!
//! A dynamic, schema-driven CLI for Google Workspace APIs.
//! This tool dynamically parses Google API Discovery Documents to construct CLI commands.
//! It supports deep schema validation, OAuth / Service Account authentication,
//! interactive prompts, and integration with Model Armor.
mod auth;
pub(crate) mod auth_commands;
mod client;
mod commands;
pub(crate) mod credential_store;
mod discovery;
mod error;
mod executor;
mod formatter;
mod fs_util;
mod generate_skills;
mod helpers;
mod logging;
mod oauth_config;
mod output;
mod schema;
mod services;
mod setup;
mod setup_tui;
mod text;
mod timezone;
mod token_storage;
pub(crate) mod validate;
use error::{print_error_json, GwsError};
#[tokio::main]
async fn main() {
// Load .env file if present (silently ignored if missing)
let _ = dotenvy::dotenv();
// Initialize structured logging (no-op if env vars are unset)
logging::init_logging();
if let Err(err) = run().await {
print_error_json(&err);
std::process::exit(err.exit_code());
}
}
async fn run() -> Result<(), GwsError> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
print_usage();
return Err(GwsError::Validation(
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
.to_string(),
));
}
// Find the first non-flag arg (skip --api-version and its value)
let mut first_arg: Option<String> = None;
{
let mut skip_next = false;
for a in args.iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if a == "--api-version" {
skip_next = true;
continue;
}
if a.starts_with("--api-version=") {
continue;
}
if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" {
first_arg = Some(a.clone());
break;
}
}
}
let first_arg = first_arg.ok_or_else(|| {
GwsError::Validation(
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
.to_string(),
)
})?;
// Handle --help and --version at top level
if is_help_flag(&first_arg) {
print_usage();
return Ok(());
}
if is_version_flag(&first_arg) {
println!("gws {}", env!("CARGO_PKG_VERSION"));
println!("This is not an officially supported Google product.");
return Ok(());
}
// Handle the `schema` command
if first_arg == "schema" {
if args.len() < 3 {
return Err(GwsError::Validation(
"Usage: gws schema <service.resource.method> (e.g., gws schema drive.files.list) [--resolve-refs]"
.to_string(),
));
}
let resolve_refs = args.iter().any(|arg| arg == "--resolve-refs");
// Remove the flag if it exists so it doesn't mess up path parsing, or just pass the path
// The path is args[2], flags might follow.
let path = &args[2];
return schema::handle_schema_command(path, resolve_refs).await;
}
// Handle the `generate-skills` command
if first_arg == "generate-skills" {
let gen_args: Vec<String> = args.iter().skip(2).cloned().collect();
return generate_skills::handle_generate_skills(&gen_args).await;
}
// Handle the `auth` command
if first_arg == "auth" {
let auth_args: Vec<String> = args.iter().skip(2).cloned().collect();
return auth_commands::handle_auth_command(&auth_args).await;
}
// Parse service name and optional version override
let (api_name, version) = parse_service_and_version(&args, &first_arg)?;
// For synthetic services (no Discovery doc), use an empty RestDescription
let doc = if api_name == "workflow" {
discovery::RestDescription {
name: "workflow".to_string(),
description: Some("Cross-service productivity workflows".to_string()),
..Default::default()
}
} else {
// Fetch the Discovery Document
discovery::fetch_discovery_document(&api_name, &version)
.await
.map_err(|e| GwsError::Discovery(format!("{e:#}")))?
};
// Build the dynamic command tree (all commands shown regardless of auth state)
let cli = commands::build_cli(&doc);
// Re-parse args (skip argv[0] which is the binary, and argv[1] which is the service name)
// Filter out --api-version and its value
// Prepend "gws" as the program name since try_get_matches_from expects argv[0]
let sub_args = filter_args_for_subcommand(&args, &first_arg);
let matches = cli.try_get_matches_from(&sub_args).map_err(|e| {
// If it's a help or version display, print it and exit cleanly
if e.kind() == clap::error::ErrorKind::DisplayHelp
|| e.kind() == clap::error::ErrorKind::DisplayVersion
{
print!("{e}");
std::process::exit(0);
}
GwsError::Validation(e.to_string())
})?;
// Resolve --format flag
let output_format = match matches.get_one::<String>("format") {
Some(s) => match formatter::OutputFormat::parse(s) {
Ok(fmt) => fmt,
Err(unknown) => {
eprintln!(
"warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)"
);
formatter::OutputFormat::Json
}
},
None => formatter::OutputFormat::default(),
};
// Resolve --sanitize template (flag or env var)
let sanitize_template = matches
.get_one::<String>("sanitize")
.cloned()
.or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE").ok());
let sanitize_mode = std::env::var("GOOGLE_WORKSPACE_CLI_SANITIZE_MODE")
.map(|v| helpers::modelarmor::SanitizeMode::from_str(&v))
.unwrap_or(helpers::modelarmor::SanitizeMode::Warn);
let sanitize_config = parse_sanitize_config(sanitize_template, &sanitize_mode)?;
// Check if a helper wants to handle this command
if let Some(helper) = helpers::get_helper(&doc.name) {
if helper.handle(&doc, &matches, &sanitize_config).await? {
return Ok(());
}
}
// Walk the subcommand tree to find the target method
let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?;
let params_json = matched_args.get_one::<String>("params").map(|s| s.as_str());
let body_json = matched_args
.try_get_one::<String>("json")
.ok()
.flatten()
.map(|s| s.as_str());
let upload_path = matched_args
.try_get_one::<String>("upload")
.ok()
.flatten()
.map(|s| s.as_str());
let output_path = matched_args.get_one::<String>("output").map(|s| s.as_str());
// Validate file paths against traversal before any I/O.
// Use the returned canonical paths so the validated path is the one
// actually used for I/O (closes TOCTOU gap).
let upload_path_buf = if let Some(p) = upload_path {
Some(crate::validate::validate_safe_file_path(p, "--upload")?)
} else {
None
};
let output_path_buf = if let Some(p) = output_path {
Some(crate::validate::validate_safe_file_path(p, "--output")?)
} else {
None
};
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());
let upload = {
let upload_content_type = matched_args
.try_get_one::<String>("upload-content-type")
.ok()
.flatten()
.map(|s| s.as_str());
upload_path.map(|path| executor::UploadSource::File {
path,
content_type: upload_content_type,
})
};
let dry_run = matched_args.get_flag("dry-run");
// Build pagination config from flags
let pagination = parse_pagination_config(matched_args);
// Select the best scope for the method. Discovery Documents list scopes as
// alternatives (any one grants access). We pick the first (broadest) scope
// to avoid restrictive scopes like gmail.metadata that block query parameters.
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();
// Authenticate: try OAuth, fail with error if credentials exist but are broken
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(e) => {
// If credentials were found but failed (e.g. decryption error, invalid token),
// propagate the error instead of silently falling back to unauthenticated.
// Only fall back to None if no credentials exist at all.
let err_msg = format!("{e:#}");
// NB: matches the bail!() message in auth::load_credentials_inner
if err_msg.starts_with("No credentials found") {
(None, executor::AuthMethod::None)
} else {
return Err(GwsError::Auth(format!("Authentication failed: {err_msg}")));
}
}
};
// Execute
executor::execute_method(
&doc,
method,
params_json,
body_json,
token.as_deref(),
auth_method,
output_path,
upload,
dry_run,
&pagination,
sanitize_config.template.as_deref(),
&sanitize_config.mode,
&output_format,
false,
)
.await
.map(|_| ())
}
/// Select the best scope from a method's scope list.
///
/// Discovery Documents list method scopes as alternatives — any single scope
/// grants access. The first scope is typically the broadest. Using all scopes
/// causes issues when restrictive scopes (e.g., `gmail.metadata`) are included,
/// as the API enforces that scope's restrictions even when broader scopes are
/// also present.
pub(crate) fn select_scope(scopes: &[String]) -> Option<&str> {
scopes.first().map(|s| s.as_str())
}
fn parse_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig {
executor::PaginationConfig {
page_all: matches.get_flag("page-all"),
page_limit: matches.get_one::<u32>("page-limit").copied().unwrap_or(10),
page_delay_ms: matches.get_one::<u64>("page-delay").copied().unwrap_or(100),
}
}
pub fn parse_service_and_version(
args: &[String],
first_arg: &str,
) -> Result<(String, String), GwsError> {
let mut service_arg = first_arg;
let mut version_override: Option<String> = None;
// Check for --api-version flag anywhere in args
for i in 0..args.len() {
if args[i] == "--api-version" && i + 1 < args.len() {
version_override = Some(args[i + 1].clone());
}
}
// Support "service:version" syntax on the service arg itself
if let Some((svc, ver)) = service_arg.split_once(':') {
service_arg = svc;
if version_override.is_none() {
version_override = Some(ver.to_string());
}
}
let (api_name, default_version) = services::resolve_service(service_arg)?;
let version = version_override.unwrap_or(default_version);
Ok((api_name, version))
}
pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec<String> {
let mut sub_args: Vec<String> = vec!["gws".to_string()];
let mut skip_next = false;
let mut service_skipped = false;
for arg in args.iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--api-version" {
skip_next = true;
continue;
}
if arg.starts_with("--api-version=") {
continue;
}
if !service_skipped && arg == service_name {
service_skipped = true;
continue;
}
sub_args.push(arg.clone());
}
sub_args
}
fn parse_sanitize_config(
template: Option<String>,
mode: &helpers::modelarmor::SanitizeMode,
) -> Result<helpers::modelarmor::SanitizeConfig, GwsError> {
Ok(helpers::modelarmor::SanitizeConfig {
template,
mode: mode.clone(),
})
}
/// Recursively walks clap ArgMatches to find the leaf method and its matches.
fn resolve_method_from_matches<'a>(
doc: &'a discovery::RestDescription,
matches: &'a clap::ArgMatches,
) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches), GwsError> {
// Walk the subcommand chain
let mut path: Vec<&str> = Vec::new();
let mut current_matches = matches;
while let Some((sub_name, sub_matches)) = current_matches.subcommand() {
path.push(sub_name);
current_matches = sub_matches;
}
if path.is_empty() {
return Err(GwsError::Validation(
"No resource or method specified".to_string(),
));
}
// path looks like ["files", "list"] or ["files", "permissions", "list"]
// Walk the Discovery Document resources to find the method
let resource_name = path[0];
let resource = doc
.resources
.get(resource_name)
.ok_or_else(|| GwsError::Validation(format!("Resource '{resource_name}' not found")))?;
let mut current_resource = resource;
// Navigate sub-resources (everything except the last element, which is the method)
for &name in &path[1..path.len() - 1] {
// Check if this is a sub-resource
if let Some(sub) = current_resource.resources.get(name) {
current_resource = sub;
} else {
return Err(GwsError::Validation(format!(
"Sub-resource '{name}' not found"
)));
}
}
// The last element is the method name
let method_name = path[path.len() - 1];
// Check if this is a method on the current resource
if let Some(method) = current_resource.methods.get(method_name) {
return Ok((method, current_matches));
}
// Maybe it's a resource that has methods — need one more subcommand
Err(GwsError::Validation(format!(
"Method '{method_name}' not found on resource. Available methods: {:?}",
current_resource.methods.keys().collect::<Vec<_>>()
)))
}
fn print_usage() {
println!("gws — Google Workspace CLI");
println!();
println!("USAGE:");
println!(" gws <service> <resource> [sub-resource] <method> [flags]");
println!(" gws schema <service.resource.method> [--resolve-refs]");
println!();
println!("EXAMPLES:");
println!(" gws drive files list --params '{{\"pageSize\": 10}}'");
println!(" gws drive files get --params '{{\"fileId\": \"abc123\"}}'");
println!(" gws sheets spreadsheets get --params '{{\"spreadsheetId\": \"...\"}}'");
println!(" gws gmail users messages list --params '{{\"userId\": \"me\"}}'");
println!(" gws schema drive.files.list");
println!();
println!("FLAGS:");
println!(" --params <JSON> URL/Query parameters as JSON");
println!(" --json <JSON> Request body as JSON (POST/PATCH/PUT)");
println!(" --upload <PATH> Local file to upload as media content (multipart)");
println!(" --upload-content-type <MIME> MIME type of the uploaded file (auto-detected from extension if omitted)");
println!(" --output <PATH> Output file path for binary responses");
println!(" --format <FMT> Output format: json (default), table, yaml, csv");
println!(" --api-version <VER> Override the API version (e.g., v2, v3)");
println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)");
println!(" --page-limit <N> Max pages to fetch with --page-all (default: 10)");
println!(" --page-delay <MS> Delay between pages in ms (default: 100)");
println!();
println!("SERVICES:");
for entry in services::SERVICES {
let name = entry.aliases[0];
let aliases = if entry.aliases.len() > 1 {
format!(" (also: {})", entry.aliases[1..].join(", "))
} else {
String::new()
};
println!(" {:<20} {}{}", name, entry.description, aliases);
}
println!();
println!("ENVIRONMENT:");
println!(" GOOGLE_WORKSPACE_CLI_TOKEN Pre-obtained OAuth2 access token (highest priority)");
println!(" GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE Path to OAuth credentials JSON file");
println!(" GOOGLE_WORKSPACE_CLI_CLIENT_ID OAuth client ID (for gws auth login)");
println!(
" GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"
);
println!(
" GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)"
);
println!(
" GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND Keyring backend: keyring (default) or file"
);
println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template");
println!(
" GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block"
);
println!(
" GOOGLE_WORKSPACE_PROJECT_ID Override the GCP project ID for quota and billing"
);
println!(" GOOGLE_WORKSPACE_CLI_LOG Log level for stderr (e.g., gws=debug)");
println!(
" GOOGLE_WORKSPACE_CLI_LOG_FILE Directory for JSON log files (daily rotation)"
);
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");
println!(" Please search existing issues first; if one already exists, comment there.");
println!();
println!("DISCLAIMER:");
println!(" This is not an officially supported Google product.");
}
fn is_help_flag(arg: &str) -> bool {
matches!(arg, "--help" | "-h")
}
fn is_version_flag(arg: &str) -> bool {
matches!(arg, "--version" | "-V" | "version")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pagination_config_defaults() {
let matches = clap::Command::new("test")
.arg(
clap::Arg::new("page-all")
.long("page-all")
.action(clap::ArgAction::SetTrue),
)
.arg(
clap::Arg::new("page-limit")
.long("page-limit")
.value_parser(clap::value_parser!(u32)),
)
.arg(
clap::Arg::new("page-delay")
.long("page-delay")
.value_parser(clap::value_parser!(u64)),
)
.get_matches_from(vec!["test"]);
let config = parse_pagination_config(&matches);
assert_eq!(config.page_all, false);
assert_eq!(config.page_limit, 10);
assert_eq!(config.page_delay_ms, 100);
}
#[test]
fn test_parse_pagination_config_custom() {
let matches = clap::Command::new("test")
.arg(
clap::Arg::new("page-all")
.long("page-all")
.action(clap::ArgAction::SetTrue),
)
.arg(
clap::Arg::new("page-limit")
.long("page-limit")
.value_parser(clap::value_parser!(u32)),
)
.arg(
clap::Arg::new("page-delay")
.long("page-delay")
.value_parser(clap::value_parser!(u64)),
)
.get_matches_from(vec![
"test",
"--page-all",
"--page-limit",
"20",
"--page-delay",
"500",
]);
let config = parse_pagination_config(&matches);
assert_eq!(config.page_all, true);
assert_eq!(config.page_limit, 20);
assert_eq!(config.page_delay_ms, 500);
}
#[test]
fn test_parse_sanitize_config_valid() {
let config = parse_sanitize_config(
Some("tpl".to_string()),
&helpers::modelarmor::SanitizeMode::Warn,
)
.unwrap();
assert_eq!(config.template.as_deref(), Some("tpl"));
}
#[test]
fn test_parse_sanitize_config_no_template() {
let config =
parse_sanitize_config(None, &helpers::modelarmor::SanitizeMode::Block).unwrap();
assert!(config.template.is_none());
assert_eq!(config.mode, helpers::modelarmor::SanitizeMode::Block);
}
#[test]
fn test_is_version_flag() {
assert!(is_version_flag("--version"));
assert!(is_version_flag("-V"));
assert!(is_version_flag("version"));
assert!(!is_version_flag("--ver"));
assert!(!is_version_flag("v"));
assert!(!is_version_flag("drive"));
}
#[test]
fn test_is_help_flag() {
assert!(is_help_flag("--help"));
assert!(is_help_flag("-h"));
assert!(!is_help_flag("help"));
assert!(!is_help_flag("--h"));
}
#[test]
fn test_resolve_method_from_matches_basic() {
let mut resources = std::collections::HashMap::new();
let mut files_res = crate::discovery::RestResource::default();
files_res.methods.insert(
"list".to_string(),
crate::discovery::RestMethod {
id: Some("drive.files.list".to_string()),
http_method: "GET".to_string(),
..Default::default()
},
);
resources.insert("files".to_string(), files_res);
let doc = discovery::RestDescription {
name: "drive".to_string(),
resources,
..Default::default()
};
// Simulate CLI structure
let cmd = clap::Command::new("gws")
.subcommand(clap::Command::new("files").subcommand(clap::Command::new("list")));
let matches = cmd.get_matches_from(vec!["gws", "files", "list"]);
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
assert_eq!(method.id.as_deref(), Some("drive.files.list"));
}
#[test]
fn test_resolve_method_from_matches_nested() {
let mut resources = std::collections::HashMap::new();
let mut files_res = crate::discovery::RestResource::default();
let mut permissions_res = crate::discovery::RestResource::default();
permissions_res.methods.insert(
"get".to_string(),
crate::discovery::RestMethod {
id: Some("drive.files.permissions.get".to_string()),
..Default::default()
},
);
files_res
.resources
.insert("permissions".to_string(), permissions_res);
resources.insert("files".to_string(), files_res);
let doc = discovery::RestDescription {
name: "drive".to_string(),
resources,
..Default::default()
};
let cmd =
clap::Command::new("gws").subcommand(clap::Command::new("files").subcommand(
clap::Command::new("permissions").subcommand(clap::Command::new("get")),
));
let matches = cmd.get_matches_from(vec!["gws", "files", "permissions", "get"]);
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get"));
}
#[test]
fn test_filter_args_strips_api_version() {
let args: Vec<String> = vec![
"gws".into(),
"drive".into(),
"--api-version".into(),
"v3".into(),
"files".into(),
"list".into(),
];
let filtered = filter_args_for_subcommand(&args, "drive");
assert_eq!(filtered, vec!["gws", "files", "list"]);
}
#[test]
fn test_filter_args_no_special_flags() {
let args: Vec<String> = vec![
"gws".into(),
"drive".into(),
"files".into(),
"list".into(),
"--format".into(),
"table".into(),
];
let filtered = filter_args_for_subcommand(&args, "drive");
assert_eq!(filtered, vec!["gws", "files", "list", "--format", "table"]);
}
#[test]
fn test_select_scope_picks_first() {
let scopes = vec![
"https://mail.google.com/".to_string(),
"https://www.googleapis.com/auth/gmail.metadata".to_string(),
"https://www.googleapis.com/auth/gmail.modify".to_string(),
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
];
assert_eq!(select_scope(&scopes), Some("https://mail.google.com/"));
}
#[test]
fn test_select_scope_single() {
let scopes = vec!["https://www.googleapis.com/auth/drive".to_string()];
assert_eq!(
select_scope(&scopes),
Some("https://www.googleapis.com/auth/drive")
);
}
#[test]
fn test_select_scope_empty() {
let scopes: Vec<String> = vec![];
assert_eq!(select_scope(&scopes), None);
}
}
@@ -0,0 +1,257 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Helpers for the OAuth client configuration file.
//!
//! Uses the standard Google Cloud Console "installed application" JSON format:
//! ```json
//! {
//! "installed": {
//! "client_id": "...apps.googleusercontent.com",
//! "project_id": "my-project",
//! "auth_uri": "https://accounts.google.com/o/oauth2/auth",
//! "token_uri": "https://oauth2.googleapis.com/token",
//! "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
//! "client_secret": "GOCSPX-...",
//! "redirect_uris": ["http://localhost"]
//! }
//! }
//! ```
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// The "installed" application config from Google Cloud Console.
#[derive(Debug, Serialize, Deserialize)]
pub struct InstalledConfig {
pub client_id: String,
pub client_secret: String,
pub project_id: String,
pub auth_uri: String,
pub token_uri: String,
#[serde(default)]
pub auth_provider_x509_cert_url: String,
#[serde(default)]
pub redirect_uris: Vec<String>,
}
/// Wrapper matching the Google Cloud Console download format.
#[derive(Debug, Serialize, Deserialize)]
pub struct ClientSecretFile {
pub installed: InstalledConfig,
}
/// Returns the path for the client secret config file.
pub fn client_config_path() -> PathBuf {
crate::auth_commands::config_dir().join("client_secret.json")
}
/// Saves OAuth client configuration in the standard Google Cloud Console format.
pub fn save_client_config(
client_id: &str,
client_secret: &str,
project_id: &str,
) -> anyhow::Result<PathBuf> {
let config = ClientSecretFile {
installed: InstalledConfig {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
project_id: project_id.to_string(),
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_uri: "https://oauth2.googleapis.com/token".to_string(),
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(),
redirect_uris: vec!["http://localhost".to_string()],
},
};
let path = client_config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&config)?;
crate::fs_util::atomic_write(&path, json.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write client config: {e}"))?;
Ok(path)
}
/// Loads OAuth client configuration from the standard Google Cloud Console format.
pub fn load_client_config() -> anyhow::Result<InstalledConfig> {
let path = client_config_path();
let data = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?;
let file: ClientSecretFile = serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("Invalid client_secret.json format: {e}"))?;
Ok(file.installed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("client_secret.json");
let config = ClientSecretFile {
installed: InstalledConfig {
client_id: "test-id.apps.googleusercontent.com".to_string(),
client_secret: "GOCSPX-test".to_string(),
project_id: "my-project".to_string(),
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_uri: "https://oauth2.googleapis.com/token".to_string(),
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs"
.to_string(),
redirect_uris: vec!["http://localhost".to_string()],
},
};
let json = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(&path, &json).unwrap();
let data = std::fs::read_to_string(&path).unwrap();
let loaded: ClientSecretFile = serde_json::from_str(&data).unwrap();
assert_eq!(
loaded.installed.client_id,
"test-id.apps.googleusercontent.com"
);
assert_eq!(loaded.installed.client_secret, "GOCSPX-test");
assert_eq!(loaded.installed.project_id, "my-project");
}
#[test]
fn test_parse_google_console_format() {
// Real format from Google Cloud Console download
let json = r#"{
"installed": {
"client_id": "test-client-id.apps.googleusercontent.com",
"project_id": "test-project-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "test-client-secret",
"redirect_uris": ["http://localhost"]
}
}"#;
let config: ClientSecretFile = serde_json::from_str(json).unwrap();
assert_eq!(config.installed.project_id, "test-project-id");
assert_eq!(config.installed.client_secret, "test-client-secret");
assert_eq!(config.installed.redirect_uris, vec!["http://localhost"]);
}
#[test]
fn test_parse_missing_optional_fields() {
// Minimal format — only required fields
let json = r#"{
"installed": {
"client_id": "test-id",
"project_id": "test-project",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"client_secret": "secret"
}
}"#;
let config: ClientSecretFile = serde_json::from_str(json).unwrap();
assert_eq!(config.installed.client_id, "test-id");
assert!(config.installed.redirect_uris.is_empty());
assert!(config.installed.auth_provider_x509_cert_url.is_empty());
}
#[test]
fn test_parse_invalid_json_fails() {
let json = r#"{ "wrong_key": {} }"#;
let result = serde_json::from_str::<ClientSecretFile>(json);
assert!(result.is_err());
}
#[test]
fn test_parse_missing_client_id_fails() {
let json = r#"{
"installed": {
"project_id": "test",
"auth_uri": "https://example.com",
"token_uri": "https://example.com",
"client_secret": "secret"
}
}"#;
let result = serde_json::from_str::<ClientSecretFile>(json);
assert!(result.is_err());
}
// Helper to manage the env var safely and clean up automatically
struct EnvGuard {
key: String,
original_value: Option<String>,
}
impl EnvGuard {
fn new(key: &str, value: &str) -> Self {
let original_value = std::env::var(key).ok();
std::env::set_var(key, value);
Self {
key: key.to_string(),
original_value,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(val) = &self.original_value {
std::env::set_var(&self.key, val);
} else {
std::env::remove_var(&self.key);
}
}
}
#[test]
#[serial_test::serial]
fn test_load_client_config() {
let dir = tempfile::tempdir().unwrap();
let _env_guard = EnvGuard::new(
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR",
dir.path().to_str().unwrap(),
);
// Initially no config file exists
let result = load_client_config();
let err = result.unwrap_err();
assert!(err.to_string().contains("Cannot read"));
// Create a valid config file
save_client_config("test-id", "test-secret", "test-project").unwrap();
// Now loading should succeed
let config = load_client_config().unwrap();
assert_eq!(config.client_id, "test-id");
assert_eq!(config.client_secret, "test-secret");
assert_eq!(config.project_id, "test-project");
// Create an invalid config file
let path = client_config_path();
std::fs::write(&path, "invalid json").unwrap();
let result = load_client_config();
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("Invalid client_secret.json format"));
}
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Shared output helpers for terminal sanitization, coloring, and stderr
//! messaging.
//!
//! Every function that prints untrusted content to the terminal should use
//! these helpers to prevent escape-sequence injection, Unicode spoofing,
//! and to respect `NO_COLOR` / non-TTY environments.
// Import dangerous-char detection from the library crate.
pub(crate) use google_workspace::validate::is_dangerous_unicode;
// ── Sanitization ──────────────────────────────────────────────────────
/// Strip dangerous characters from untrusted text before printing to the
/// terminal. Removes ASCII control characters (except `\n` and `\t`,
/// which are preserved for readability) and dangerous Unicode characters
/// (bidi overrides, zero-width chars, line/paragraph separators).
pub(crate) fn sanitize_for_terminal(text: &str) -> String {
text.chars()
.filter(|&c| {
if c == '\n' || c == '\t' {
return true;
}
if c.is_control() {
return false;
}
!is_dangerous_unicode(c)
})
.collect()
}
// ── Color ─────────────────────────────────────────────────────────────
/// Returns true when stderr is connected to an interactive terminal and
/// `NO_COLOR` is not set, meaning ANSI color codes will be visible.
pub(crate) fn stderr_supports_color() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
/// Wrap `text` in ANSI bold + the given color code, resetting afterwards.
/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR`
/// is set.
pub(crate) fn colorize(text: &str, ansi_color: &str) -> String {
if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) {
format!("\x1b[1;{ansi_color}m{text}\x1b[0m")
} else {
text.to_string()
}
}
// ── Stderr helpers ────────────────────────────────────────────────────
/// Print a status message to stderr. The message is sanitized before
/// printing to prevent terminal injection.
#[allow(dead_code)]
pub(crate) fn status(msg: &str) {
eprintln!("{}", sanitize_for_terminal(msg));
}
/// Print a warning to stderr with a colored prefix. The message is
/// sanitized before printing.
#[allow(dead_code)]
pub(crate) fn warn(msg: &str) {
let prefix = colorize("warning:", "33"); // yellow
eprintln!("{prefix} {}", sanitize_for_terminal(msg));
}
/// Print an informational message to stderr. The message is sanitized
/// before printing.
#[allow(dead_code)]
pub(crate) fn info(msg: &str) {
eprintln!("{}", sanitize_for_terminal(msg));
}
#[cfg(test)]
mod tests {
use super::*;
// ── sanitize_for_terminal ─────────────────────────────────────
#[test]
fn sanitize_strips_ansi_escape_sequences() {
let input = "normal \x1b[31mred text\x1b[0m end";
let sanitized = sanitize_for_terminal(input);
assert_eq!(sanitized, "normal [31mred text[0m end");
assert!(!sanitized.contains('\x1b'));
}
#[test]
fn sanitize_preserves_newlines_and_tabs() {
let input = "line1\nline2\ttab";
assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab");
}
#[test]
fn sanitize_strips_bell_and_backspace() {
let input = "hello\x07bell\x08backspace";
assert_eq!(sanitize_for_terminal(input), "hellobellbackspace");
}
#[test]
fn sanitize_strips_carriage_return() {
let input = "real\rfake";
assert_eq!(sanitize_for_terminal(input), "realfake");
}
#[test]
fn sanitize_strips_bidi_overrides() {
let input = "hello\u{202E}dlrow";
assert_eq!(sanitize_for_terminal(input), "hellodlrow");
}
#[test]
fn sanitize_strips_zero_width_chars() {
assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar");
assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar");
}
#[test]
fn sanitize_strips_line_separators() {
assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2");
assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2");
}
#[test]
fn sanitize_strips_directional_isolates() {
assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc");
}
#[test]
fn sanitize_preserves_normal_unicode() {
assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ");
}
// ── colorize ──────────────────────────────────────────────────
#[test]
fn colorize_returns_text_in_no_color_mode() {
let result = colorize("hello", "31");
assert!(result.contains("hello"));
}
}
+468
View File
@@ -0,0 +1,468 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! JSON Schema Validation & Reference Resolution
//!
//! Provides utilities to validate JSON payloads against the Google API Discovery Document
//! schemas before dispatching requests. This ensures immediate client-side feedback
//! for invalid API payloads.
use serde_json::{json, Value};
use crate::discovery::{
fetch_discovery_document, JsonSchema, MethodParameter, RestDescription, RestMethod,
RestResource,
};
use crate::error::GwsError;
use crate::services::resolve_service;
/// Handles the `gws schema <dotted.path>` command.
///
/// Path format: `service.resource[.subresource].method`
/// Example: `drive.files.list` or `drive.files.permissions.list`
pub async fn handle_schema_command(path: &str, resolve_refs: bool) -> Result<(), GwsError> {
let parts: Vec<&str> = path.split('.').collect();
if parts.len() < 2 {
return Err(GwsError::Validation(format!(
"Schema path must be at least 'service.Message' or 'service.resource.method', got '{path}'"
)));
}
let service_name = parts[0];
let (api_name, version) = resolve_service(service_name)?;
let doc = fetch_discovery_document(&api_name, &version)
.await
.map_err(|e| GwsError::Discovery(format!("{e:#}")))?;
// Case 1: Schema lookup (e.g., "drive.File")
if parts.len() == 2 {
let schema_name = parts[1];
if let Some(schema) = doc.schemas.get(schema_name) {
let mut output = schema_to_json(schema);
if resolve_refs {
let mut seen = std::collections::HashSet::new();
// Add self to seen to prevent immediate recursion
seen.insert(schema_name.to_string());
resolve_schema_refs(&mut output, &doc, &mut seen);
}
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
);
return Ok(());
} else {
// It might be a resource path that is incomplete, but let's see if it's a schema typo first
// or perhaps the user meant "drive.files" (resource) which we don't support dumping yet.
// Let's check if it matches a resource name to give a better error.
if doc.resources.contains_key(schema_name) {
return Err(GwsError::Validation(format!(
"'{schema_name}' is a resource. To see its methods, try 'gws schema {service_name}.{schema_name}.list' (or similar). To see a type definition, try 'gws schema {service_name}.<Type>'."
)));
}
let available: Vec<&String> = doc.schemas.keys().collect();
return Err(GwsError::Validation(format!(
"Schema or resource '{schema_name}' not found. Available schemas: {:?}",
available
)));
}
}
// Case 2: Method lookup (e.g., "drive.files.list")
let resource_path = &parts[1..parts.len() - 1];
let method_name = parts[parts.len() - 1];
let method = find_method(&doc, resource_path, method_name)?;
let mut output = build_schema_output(&doc, method);
if resolve_refs {
let mut seen = std::collections::HashSet::new();
resolve_schema_refs(&mut output, &doc, &mut seen);
}
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
);
Ok(())
}
/// Walks the resource tree to find a method.
fn find_method<'a>(
doc: &'a RestDescription,
resource_path: &[&str],
method_name: &str,
) -> Result<&'a RestMethod, GwsError> {
if resource_path.is_empty() {
return Err(GwsError::Validation(
"Resource path cannot be empty".to_string(),
));
}
let first_resource_name = resource_path[0];
let resource = doc.resources.get(first_resource_name).ok_or_else(|| {
let available: Vec<&String> = doc.resources.keys().collect();
GwsError::Validation(format!(
"Resource '{}' not found. Available resources: {:?}",
first_resource_name, available
))
})?;
// Walk deeper into sub-resources
let mut current_resource: &RestResource = resource;
for &sub_name in &resource_path[1..] {
current_resource = current_resource.resources.get(sub_name).ok_or_else(|| {
let available: Vec<&String> = current_resource.resources.keys().collect();
GwsError::Validation(format!(
"Sub-resource '{}' not found. Available: {:?}",
sub_name, available
))
})?;
}
current_resource.methods.get(method_name).ok_or_else(|| {
let available: Vec<&String> = current_resource.methods.keys().collect();
GwsError::Validation(format!(
"Method '{}' not found. Available methods: {:?}",
method_name, available
))
})
}
/// Builds the schema output JSON for a method.
fn build_schema_output(doc: &RestDescription, method: &RestMethod) -> Value {
let mut params = json!({});
for (name, param) in &method.parameters {
params[name] = param_to_json(param);
}
let mut output = json!({
"httpMethod": method.http_method,
"path": method.path,
"description": method.description.as_deref().unwrap_or(""),
"parameters": params,
"scopes": method.scopes,
});
if !method.parameter_order.is_empty() {
output["parameterOrder"] = json!(method.parameter_order);
}
// Resolve request body schema
if let Some(ref req_ref) = method.request {
if let Some(ref schema_name) = req_ref.schema_ref {
output["requestBody"] = json!({
"schemaRef": schema_name,
});
if let Some(schema) = doc.schemas.get(schema_name) {
output["requestBody"]["schema"] = schema_to_json(schema);
}
}
}
// Response schema ref
if let Some(ref resp_ref) = method.response {
if let Some(ref schema_name) = resp_ref.schema_ref {
output["response"] = json!({
"schemaRef": schema_name,
});
// Also inline the response schema structure if available
if let Some(schema) = doc.schemas.get(schema_name) {
output["response"]["schema"] = schema_to_json(schema);
}
}
}
output
}
fn param_to_json(param: &MethodParameter) -> Value {
let mut p = json!({
"type": param.param_type.as_deref().unwrap_or("string"),
"required": param.required,
});
if let Some(ref loc) = param.location {
p["location"] = json!(loc);
}
if let Some(ref desc) = param.description {
p["description"] = json!(desc);
}
if let Some(ref fmt) = param.format {
p["format"] = json!(fmt);
}
if let Some(ref def) = param.default {
p["default"] = json!(def);
}
if let Some(ref vals) = param.enum_values {
p["enum"] = json!(vals);
}
if param.repeated {
p["repeated"] = json!(true);
}
if param.deprecated {
p["deprecated"] = json!(true);
}
p
}
fn schema_to_json(schema: &JsonSchema) -> Value {
let mut s = json!({});
if let Some(ref t) = schema.schema_type {
s["type"] = json!(t);
}
if let Some(ref desc) = schema.description {
s["description"] = json!(desc);
}
if !schema.properties.is_empty() {
let mut props = json!({});
for (name, prop) in &schema.properties {
let mut p = json!({});
if let Some(ref t) = prop.prop_type {
p["type"] = json!(t);
}
if let Some(ref r) = prop.schema_ref {
p["$ref"] = json!(r);
}
if let Some(ref desc) = prop.description {
p["description"] = json!(desc);
}
if prop.read_only {
p["readOnly"] = json!(true);
}
if let Some(ref fmt) = prop.format {
p["format"] = json!(fmt);
}
// Handle items for array types
if let Some(ref items) = prop.items {
let mut items_json = json!({});
if let Some(ref t) = items.prop_type {
items_json["type"] = json!(t);
}
if let Some(ref r) = items.schema_ref {
items_json["$ref"] = json!(r);
}
p["items"] = items_json;
}
props[name] = p;
}
s["properties"] = props;
}
s
}
/// Recursively resolves "$ref" fields in the JSON value.
fn resolve_schema_refs(
val: &mut Value,
doc: &RestDescription,
seen: &mut std::collections::HashSet<String>,
) {
match val {
Value::Object(map) => {
// Check if this object is a reference
if let Some(ref_name) = map
.get("$ref")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
{
// If we haven't seen this schema yet in this branch
if !seen.contains(&ref_name) {
if let Some(schema) = doc.schemas.get(&ref_name) {
seen.insert(ref_name.clone());
let mut resolved = schema_to_json(schema);
// Recursively resolve the resolved schema
resolve_schema_refs(&mut resolved, doc, seen);
seen.remove(&ref_name);
// Merge resolved schema into current object, but preserve existing fields
// (though usually $ref stands alone)
if let Value::Object(resolved_map) = resolved {
for (k, v) in resolved_map {
map.entry(k).or_insert(v);
}
}
}
}
}
// Recurse into all fields
for (_, v) in map.iter_mut() {
resolve_schema_refs(v, doc, seen);
}
}
Value::Array(arr) => {
for v in arr {
resolve_schema_refs(v, doc, seen);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_param_to_json() {
let param = MethodParameter {
param_type: Some("integer".to_string()),
description: Some("desc".to_string()),
location: Some("query".to_string()),
required: true,
format: Some("int32".to_string()),
default: Some("0".to_string()),
enum_values: Some(vec!["0".to_string(), "1".to_string()]),
enum_descriptions: None,
repeated: false,
minimum: None,
maximum: None,
deprecated: true,
};
let json = param_to_json(&param);
assert_eq!(json["type"], "integer");
assert_eq!(json["description"], "desc");
assert_eq!(json["location"], "query");
assert_eq!(json["required"], true);
assert_eq!(json["format"], "int32");
assert_eq!(json["default"], "0");
assert!(json["enum"].is_array());
assert_eq!(json["deprecated"], true);
// repeated: false should NOT appear in output
assert!(json.get("repeated").is_none());
}
#[test]
fn test_param_to_json_repeated() {
let param = MethodParameter {
param_type: Some("string".to_string()),
location: Some("query".to_string()),
repeated: true,
..Default::default()
};
let json = param_to_json(&param);
assert_eq!(json["type"], "string");
assert_eq!(json["repeated"], true);
}
#[test]
fn test_schema_to_json_basic() {
let mut properties = std::collections::HashMap::new();
properties.insert(
"name".to_string(),
crate::discovery::JsonSchemaProperty {
prop_type: Some("string".to_string()),
..Default::default()
},
);
let schema = JsonSchema {
schema_type: Some("object".to_string()),
properties,
..Default::default()
};
let json = schema_to_json(&schema);
assert_eq!(json["type"], "object");
assert!(json["properties"].is_object());
assert_eq!(json["properties"]["name"]["type"], "string");
}
#[test]
fn test_resolve_schema_refs_basic() {
let mut schemas = std::collections::HashMap::new();
let target_schema = JsonSchema {
schema_type: Some("string".to_string()),
description: Some("Resolved type".to_string()),
..Default::default()
};
schemas.insert("Target".to_string(), target_schema);
let doc = RestDescription {
schemas,
..Default::default()
};
let mut val = json!({
"$ref": "Target"
});
let mut seen = std::collections::HashSet::new();
resolve_schema_refs(&mut val, &doc, &mut seen);
assert_eq!(val["type"], "string");
assert_eq!(val["description"], "Resolved type");
// $ref might remain or effectively be merged, checking properties is key
}
#[test]
fn test_resolve_schema_refs_nested() {
let mut schemas = std::collections::HashMap::new();
let child = JsonSchema {
schema_type: Some("integer".to_string()),
..Default::default()
};
schemas.insert("Child".to_string(), child);
let parent = JsonSchema {
schema_type: Some("object".to_string()),
properties: {
let mut map = std::collections::HashMap::new();
map.insert(
"f".to_string(),
crate::discovery::JsonSchemaProperty {
schema_ref: Some("Child".to_string()),
..Default::default()
},
);
map
},
..Default::default()
};
schemas.insert("Parent".to_string(), parent);
let doc = RestDescription {
schemas,
..Default::default()
};
let mut val = json!({
"$ref": "Parent"
});
let mut seen = std::collections::HashSet::new();
resolve_schema_refs(&mut val, &doc, &mut seen);
// Check Parent resolved
assert_eq!(val["type"], "object");
// Check Child resolved inside Parent
// note: schema_to_json converts ref to $ref property, then resolve_schema_refs follows it
// The implementation matches on "$ref" keys in objects.
// schema_to_json for Parent produces { properties: { f: { $ref: "Child" } } }
// The recursion should resolve f.$ref to Child content.
let f_node = &val["properties"]["f"];
assert_eq!(f_node["type"], "integer");
}
}
@@ -0,0 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Service registry — re-exports from `google_workspace` library crate.
pub use google_workspace::services::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// Max chars for CLI `--help` method descriptions (terminal-width friendly).
pub const CLI_DESCRIPTION_LIMIT: usize = 200;
/// Max chars for YAML frontmatter / skill index descriptions (compact metadata).
pub const FRONTMATTER_DESCRIPTION_LIMIT: usize = 120;
/// Max chars for skill body method descriptions (markdown, agents benefit from detail).
pub const SKILL_BODY_DESCRIPTION_LIMIT: usize = 500;
/// Truncates a description string to `max_chars` using smart boundaries.
///
/// When `strip_links` is true, markdown links `[text](url)` are replaced with
/// just `text` to reclaim character budget (useful for CLI help / frontmatter).
/// When false, links are preserved (useful for skill body text where agents can
/// follow URLs).
///
/// Truncation strategy:
/// 1. If a complete sentence (ending in `. `) fits within the limit, truncate there.
/// 2. Otherwise, break at the last word boundary (space) and append `…`.
/// 3. If no space exists, hard-cut at `max_chars - 1` and append `…`.
pub fn truncate_description(desc: &str, max_chars: usize, strip_links: bool) -> String {
if max_chars == 0 {
return String::new();
}
let cleaned = if strip_links {
strip_markdown_links(desc)
} else {
desc.to_string()
};
let trimmed = cleaned.trim();
// Count chars (UTF-8 safe)
let char_count = trimmed.chars().count();
if char_count <= max_chars {
return trimmed.to_string();
}
// Collect the first `max_chars` characters as a string to search within.
let prefix: String = trimmed.chars().take(max_chars).collect();
// Try to find the last complete sentence within the limit.
// A sentence ends with ". " followed by more text, or "." at the end of
// the prefix. We look for the last ". " to find a sentence boundary.
if let Some(sentence_end) = find_last_sentence_boundary(&prefix) {
let truncated: String = trimmed.chars().take(sentence_end).collect();
return truncated;
}
// Fall back to last word boundary (space) within the limit.
if let Some(last_space) = rfind_char_boundary(&prefix, ' ') {
let truncated: String = trimmed.chars().take(last_space).collect();
return format!("{truncated}");
}
// Hard cut — no spaces at all
let truncated: String = trimmed.chars().take(max_chars - 1).collect();
format!("{truncated}")
}
/// Strips markdown-style links `[text](url)` and replaces them with just `text`.
fn strip_markdown_links(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '[' {
// Look for the closing ] followed by (
if let Some(close_bracket) = find_char_from(&chars, ']', i + 1) {
if close_bracket + 1 < len && chars[close_bracket + 1] == '(' {
if let Some(close_paren) = find_char_from(&chars, ')', close_bracket + 2) {
// Found a complete [text](url) — emit just the text
result.extend(&chars[i + 1..close_bracket]);
i = close_paren + 1;
continue;
}
}
}
}
result.push(chars[i]);
i += 1;
}
result
}
/// Finds the character-index of `target` starting from position `from`.
fn find_char_from(chars: &[char], target: char, from: usize) -> Option<usize> {
chars[from..]
.iter()
.position(|&c| c == target)
.map(|p| from + p)
}
/// Finds the last sentence boundary within a char-indexed string.
/// A sentence boundary is a position right after ". " where we can cleanly cut.
/// Returns the char-count to include (up to and including the period).
fn find_last_sentence_boundary(prefix: &str) -> Option<usize> {
let chars: Vec<char> = prefix.chars().collect();
let mut last_boundary = None;
for (i, _) in chars.iter().enumerate() {
if chars[i] == '.' {
let after_period = i + 1;
// Sentence boundary: period followed by a space, or period at end of prefix
if after_period == chars.len()
|| (after_period < chars.len() && chars[after_period] == ' ')
{
last_boundary = Some(after_period);
}
}
}
last_boundary
}
/// Finds the last occurrence of `target` in a string, returning its char-index.
fn rfind_char_boundary(s: &str, target: char) -> Option<usize> {
let chars: Vec<char> = s.chars().collect();
chars.iter().rposition(|&c| c == target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_desc_unchanged() {
let desc = "Lists all files.";
assert_eq!(truncate_description(desc, 200, true), "Lists all files.");
}
#[test]
fn truncate_at_sentence_boundary() {
let desc = "Creates a file in Drive. This method supports multipart upload. See the guide for details on how to use it.";
// At limit 30, only the first sentence fits before the sentence boundary.
let result = truncate_description(desc, 30, true);
assert_eq!(result, "Creates a file in Drive.");
// At limit 70, both first and second sentences fit.
let result = truncate_description(desc, 70, true);
assert_eq!(
result,
"Creates a file in Drive. This method supports multipart upload."
);
}
#[test]
fn truncate_at_word_boundary() {
let desc = "Create a guest user with access to a subset of Workspace capabilities";
let result = truncate_description(desc, 50, true);
// Should cut at the last space before char 50
assert!(result.ends_with('…'));
assert!(result.len() <= 55); // 50 chars + ellipsis
assert!(!result.contains("capabil")); // Should not cut mid-word
}
#[test]
fn hard_cut_no_spaces() {
let desc = "abcdefghijklmnopqrstuvwxyz";
let result = truncate_description(desc, 10, true);
assert_eq!(result, "abcdefghi…");
}
#[test]
fn strips_markdown_links() {
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha.";
let result = truncate_description(desc, 200, true);
assert_eq!(
result,
"Create a guest user with access to a subset of Workspace capabilities. This feature is in Alpha."
);
assert!(!result.contains("https://"));
assert!(!result.contains('['));
}
#[test]
fn preserves_links_when_strip_links_false() {
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha.";
let result = truncate_description(desc, 500, false);
assert!(result.contains("https://support.google.com"));
assert!(result.contains("[subset of Workspace capabilities]"));
}
#[test]
fn strips_markdown_links_and_truncates() {
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is currently in Alpha. Please reach out to support if you are interested in enabling this feature.";
let result = truncate_description(desc, 120, true);
// After stripping the link, the sentence boundary should work.
assert!(result.contains("subset of Workspace capabilities."));
assert!(!result.contains("https://"));
}
#[test]
fn multibyte_safe() {
let desc = "Résumé création für Ñoño — a long description that should be safely truncated at word boundaries without panicking on multi-byte chars";
let result = truncate_description(desc, 30, true);
assert!(result.ends_with('…') || result.chars().count() <= 30);
}
#[test]
fn empty_and_whitespace() {
assert_eq!(truncate_description("", 100, true), "");
assert_eq!(truncate_description(" ", 100, true), "");
assert_eq!(truncate_description("", 0, true), "");
}
#[test]
fn test_strip_markdown_links() {
assert_eq!(strip_markdown_links("[text](http://example.com)"), "text");
assert_eq!(
strip_markdown_links("Use [this link](http://a.com) and [that](http://b.com) too"),
"Use this link and that too"
);
assert_eq!(strip_markdown_links("no links here"), "no links here");
// Incomplete link syntax should be left alone
assert_eq!(strip_markdown_links("[broken"), "[broken");
assert_eq!(strip_markdown_links("[text]no-parens"), "[text]no-parens");
}
#[test]
fn preserves_sentence_ending_at_limit() {
let desc = "Deletes a user.";
assert_eq!(truncate_description(desc, 15, true), "Deletes a user.");
}
#[test]
fn does_not_cut_url_looking_periods() {
// Periods in URLs or abbreviations like "v1." shouldn't be treated as sentence ends
// unless followed by a space
let desc = "See the docs at developers.google.com for more details on this API endpoint";
let result = truncate_description(desc, 50, true);
// Should truncate at word boundary, not at "developers."
assert!(result.ends_with('…'));
}
#[test]
fn sentence_boundary_at_exact_limit() {
// Period falls exactly at the end of the prefix — should still detect it
let desc = "This is a complete sentence. And more text follows here.";
let result = truncate_description(desc, 28, true);
assert_eq!(result, "This is a complete sentence.");
}
#[test]
fn zero_max_chars() {
assert_eq!(truncate_description("anything", 0, true), "");
}
}
+267
View File
@@ -0,0 +1,267 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Account timezone resolution for Google Workspace CLI.
//!
//! Resolves the authenticated user's timezone with the following priority:
//! 1. Explicit `--timezone` CLI flag (hard error if invalid)
//! 2. Cached value from config dir (24h TTL)
//! 3. Google Calendar Settings API (`users/me/settings/timezone`)
//! 4. Machine-local timezone (fallback with warning)
use crate::error::GwsError;
use chrono_tz::Tz;
use std::path::PathBuf;
/// Cache filename stored in the gws config directory.
const CACHE_FILENAME: &str = "account_timezone";
/// Cache TTL in seconds (24 hours).
const CACHE_TTL_SECS: u64 = 86400;
/// Returns the path to the timezone cache file.
fn cache_path() -> PathBuf {
crate::auth_commands::config_dir().join(CACHE_FILENAME)
}
/// Remove the cached timezone file. Called on auth login/logout to
/// invalidate stale values when the account changes.
pub fn invalidate_cache() {
let path = cache_path();
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache");
}
}
}
/// Read the cached timezone if it exists and is fresh (< 24h old).
fn read_cache() -> Option<Tz> {
let path = cache_path();
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
let age = std::time::SystemTime::now().duration_since(modified).ok()?;
if age.as_secs() > CACHE_TTL_SECS {
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
let tz_name = contents.trim();
tz_name.parse::<Tz>().ok()
}
/// Write a timezone name to the cache file.
fn write_cache(tz_name: &str) {
let path = cache_path();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory");
return;
}
}
if let Err(e) = std::fs::write(&path, tz_name) {
tracing::warn!(path = %path.display(), error = %e, "failed to write timezone cache");
}
}
/// Fetch the account timezone from the Google Calendar Settings API.
async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result<Tz, GwsError> {
let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone";
let resp = client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch account timezone: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "timezone_fetch_failed".to_string(),
enable_url: None,
});
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse timezone response: {e}")))?;
let tz_name = json
.get("value")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
GwsError::Other(anyhow::anyhow!(
"Timezone setting missing or empty 'value' field"
))
})?;
let tz: Tz = tz_name.parse().map_err(|_| {
GwsError::Other(anyhow::anyhow!(
"Google returned unrecognized timezone: {tz_name}"
))
})?;
// Cache for future use
write_cache(tz_name);
tracing::info!(
timezone = tz_name,
source = "calendar_api",
"resolved account timezone"
);
Ok(tz)
}
/// Parse an explicit timezone string, returning an error if invalid.
pub fn parse_timezone(tz_str: &str) -> Result<Tz, GwsError> {
tz_str.parse::<Tz>().map_err(|_| {
GwsError::Validation(format!(
"Invalid timezone '{tz_str}'. Use an IANA timezone name (e.g. America/Denver, Europe/London, UTC)."
))
})
}
/// Resolve the user's timezone with this priority:
/// 1. `tz_override` (from `--timezone` flag) — hard error if invalid
/// 2. Cached value in config dir — use if < 24h old
/// 3. Google Calendar Settings API — fetch and cache
/// 4. Machine-local timezone (log warning)
pub async fn resolve_account_timezone(
client: &reqwest::Client,
token: &str,
tz_override: Option<&str>,
) -> Result<Tz, GwsError> {
// 1. Explicit override — fail if invalid
if let Some(tz_str) = tz_override {
let tz = parse_timezone(tz_str)?;
tracing::info!(
timezone = tz_str,
source = "cli_flag",
"using explicit timezone"
);
return Ok(tz);
}
// 2. Check cache
if let Some(tz) = read_cache() {
tracing::debug!(timezone = %tz, source = "cache", "using cached timezone");
return Ok(tz);
}
// 3. Fetch from Calendar Settings API
match fetch_account_timezone(client, token).await {
Ok(tz) => return Ok(tz),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local");
}
}
// 4. Fall back to machine-local timezone
let local_iana = iana_time_zone_fallback();
tracing::warn!(
timezone = local_iana.as_str(),
source = "local_machine",
"using machine-local timezone as fallback"
);
let tz: Tz = local_iana.parse().unwrap_or(chrono_tz::UTC);
Ok(tz)
}
/// Return the start of today (midnight) in the given timezone as a
/// timezone-aware `DateTime`. Errors if midnight cannot be resolved
/// (e.g. a DST transition that skips midnight — extremely rare).
pub fn start_of_today(tz: Tz) -> Result<chrono::DateTime<Tz>, crate::error::GwsError> {
use chrono::{NaiveTime, TimeZone, Utc};
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
tz.from_local_datetime(&today_start)
.earliest()
.ok_or_else(|| {
crate::error::GwsError::Other(anyhow::anyhow!(
"Could not determine start of day in timezone '{}'",
tz
))
})
}
/// Best-effort machine-local IANA timezone detection using the
/// `iana-time-zone` crate, which reads the OS timezone database.
fn iana_time_zone_fallback() -> String {
match iana_time_zone::get_timezone() {
Ok(tz) => tz,
Err(_) => "UTC".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_iana_timezone() {
let tz = parse_timezone("America/Denver").unwrap();
assert_eq!(tz, chrono_tz::America::Denver);
}
#[test]
fn parse_utc_timezone() {
let tz = parse_timezone("UTC").unwrap();
assert_eq!(tz, chrono_tz::UTC);
}
#[test]
fn parse_invalid_timezone_fails() {
let result = parse_timezone("Not/A/Zone");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid timezone"));
assert!(err.contains("Not/A/Zone"));
}
#[test]
fn parse_empty_string_fails() {
let result = parse_timezone("");
assert!(result.is_err());
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let cache_file = dir.path().join(CACHE_FILENAME);
// Write directly to test location
std::fs::write(&cache_file, "America/New_York").unwrap();
let contents = std::fs::read_to_string(&cache_file).unwrap();
let tz: Tz = contents.trim().parse().unwrap();
assert_eq!(tz, chrono_tz::America::New_York);
}
#[test]
fn iana_fallback_returns_valid_tz() {
let tz_name = iana_time_zone_fallback();
// Should be parseable
let result: Result<Tz, _> = tz_name.parse();
assert!(
result.is_ok(),
"Fallback timezone '{tz_name}' should be parseable"
);
}
}
@@ -0,0 +1,175 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use yup_oauth2::storage::{TokenInfo, TokenStorage, TokenStorageError};
use crate::output::sanitize_for_terminal;
/// A custom token storage implementation for `yup-oauth2` that encrypts
/// the cached tokens at rest using AES-256-GCM encryption.
pub struct EncryptedTokenStorage {
file_path: PathBuf,
// Add memory cache since TokenStorage getters can be called frequently
cache: Arc<Mutex<Option<HashMap<String, TokenInfo>>>>,
}
impl EncryptedTokenStorage {
pub fn new(path: PathBuf) -> Self {
Self {
file_path: path,
cache: Arc::new(Mutex::new(None)),
}
}
async fn load_from_disk(&self) -> HashMap<String, TokenInfo> {
let data = match tokio::fs::read(&self.file_path).await {
Ok(d) => d,
Err(_) => return HashMap::new(), // File doesn't exist yet — normal on first run
};
let decrypted = match crate::credential_store::decrypt(&data) {
Ok(d) => d,
Err(e) => {
eprintln!(
"warning: failed to decrypt token cache ({}): {e:#}",
self.file_path.display()
);
eprintln!("hint: you may need to re-authenticate with `gws auth login`");
return HashMap::new();
}
};
let json = match String::from_utf8(decrypted) {
Ok(j) => j,
Err(e) => {
eprintln!(
"warning: token cache contains invalid UTF-8: {}",
sanitize_for_terminal(&e.to_string())
);
return HashMap::new();
}
};
match serde_json::from_str(&json) {
Ok(map) => map,
Err(e) => {
eprintln!(
"warning: failed to parse token cache JSON: {}",
sanitize_for_terminal(&e.to_string())
);
HashMap::new()
}
}
}
async fn save_to_disk(&self, map: &HashMap<String, TokenInfo>) -> anyhow::Result<()> {
let json = serde_json::to_string(map)?;
let encrypted = crate::credential_store::encrypt(json.as_bytes())?;
if let Some(parent) = self.file_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
anyhow::anyhow!(
"Failed to create token directory '{}': {}",
sanitize_for_terminal(&parent.display().to_string()),
e
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to set permissions on token directory '{}': {}",
sanitize_for_terminal(&parent.display().to_string()),
e
)
})?;
}
}
// Write atomically via a sibling .tmp file + rename.
crate::fs_util::atomic_write_async(&self.file_path, encrypted.as_slice()).await?;
Ok(())
}
// Helper to join scopes consistently for cache keys
fn cache_key(scopes: &[&str]) -> String {
let mut s: Vec<&str> = scopes.to_vec();
s.sort_unstable();
s.dedup();
s.join(" ")
}
}
#[async_trait::async_trait]
impl TokenStorage for EncryptedTokenStorage {
async fn set(&self, scopes: &[&str], token: TokenInfo) -> Result<(), TokenStorageError> {
let mut map_lock = self.cache.lock().await;
// Initialize cache if this is the first write
if map_lock.is_none() {
*map_lock = Some(self.load_from_disk().await);
}
if let Some(map) = map_lock.as_mut() {
map.insert(Self::cache_key(scopes), token);
self.save_to_disk(map)
.await
.map_err(|e| TokenStorageError::Other(std::borrow::Cow::Owned(e.to_string())))?;
}
Ok(())
}
async fn get(&self, scopes: &[&str]) -> Option<TokenInfo> {
let mut map_lock = self.cache.lock().await;
if map_lock.is_none() {
*map_lock = Some(self.load_from_disk().await);
}
if let Some(map) = map_lock.as_ref() {
let key = Self::cache_key(scopes);
if let Some(token) = map.get(&key) {
return Some(token.clone());
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[tokio::test]
async fn test_encrypted_token_storage_new() {
let path = PathBuf::from("/fake/path/to/token.json");
let storage = EncryptedTokenStorage::new(path.clone());
assert_eq!(storage.file_path, path);
let cache_lock = storage.cache.lock().await;
assert!(cache_lock.is_none());
}
}
@@ -0,0 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Input validation — re-exports from `google_workspace` library crate.
pub use google_workspace::validate::*;
@@ -0,0 +1,8 @@
{
"filterConfig": {
"piAndJailbreakFilterSettings": {
"filterEnforcement": "ENABLED",
"confidenceLevel": "LOW_AND_ABOVE"
}
}
}