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
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:
@@ -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"
|
||||
@@ -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\"}'`"
|
||||
]
|
||||
@@ -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(¶ms)
|
||||
.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
@@ -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::*;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ---"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms_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(¶ms).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(¶ms_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(¶ms_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(¶ms_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(¶ms_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(¶ms_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(¶ms).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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(¶m);
|
||||
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(¶m);
|
||||
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
@@ -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), "");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user