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,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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user