Skip to content

Commit b330947

Browse files
committed
feat: expose library crate for programmatic API access
Extract `config_dir()` into `src/config.rs` and Model Armor sanitization types into `src/sanitize.rs` so they can be shared between the binary and library targets without pulling in CLI-only code. Add `src/lib.rs` with public module re-exports and `tests/lib_integration.rs` with offline tests. Also moves `parse_service_and_version()` from `main.rs` to `services.rs` so it is accessible from both the lib and bin crate roots. Zero behavior changes to the binary.
1 parent 59ad873 commit b330947

16 files changed

Lines changed: 447 additions & 240 deletions

.changeset/add-library-crate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Expose library crate (`lib.rs`) for programmatic API access. Extracts `config_dir()` and Model Armor sanitization types into standalone modules so they can be shared between the binary and library targets without pulling in CLI-only code.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ authors = ["Justin Poehnelt"]
2525
keywords = ["cli", "google-workspace", "google", "drive", "gmail"]
2626
categories = ["command-line-utilities", "web-programming"]
2727

28+
[lib]
29+
name = "gws"
30+
path = "src/lib.rs"
31+
2832
[[bin]]
2933
name = "gws"
3034
path = "src/main.rs"

src/auth.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
7878
}
7979

8080
let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
81-
let config_dir = crate::auth_commands::config_dir();
81+
let config_dir = crate::config::config_dir();
8282
let enc_path = credential_store::encrypted_credentials_path();
8383
let default_path = config_dir.join("credentials.json");
8484
let token_cache = config_dir.join("token_cache.json");

src/auth_commands.rs

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -92,29 +92,7 @@ const READONLY_SCOPES: &[&str] = &[
9292
];
9393

9494
pub fn config_dir() -> PathBuf {
95-
if let Ok(dir) = std::env::var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR") {
96-
return PathBuf::from(dir);
97-
}
98-
99-
// Use ~/.config/gws on all platforms for a consistent, user-friendly path.
100-
let primary = dirs::home_dir()
101-
.unwrap_or_else(|| PathBuf::from("."))
102-
.join(".config")
103-
.join("gws");
104-
if primary.exists() {
105-
return primary;
106-
}
107-
108-
// Backward compat: fall back to OS-specific config dir for existing installs
109-
// (e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows).
110-
let legacy = dirs::config_dir()
111-
.unwrap_or_else(|| PathBuf::from("."))
112-
.join("gws");
113-
if legacy.exists() {
114-
return legacy;
115-
}
116-
117-
primary
95+
crate::config::config_dir()
11896
}
11997

12098
fn plain_credentials_path() -> PathBuf {

src/config.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::path::PathBuf;
16+
17+
/// Returns the gws configuration directory.
18+
///
19+
/// Prefers `~/.config/gws` for a consistent, cross-platform path.
20+
/// Falls back to the OS-specific config directory (e.g. `~/Library/Application Support/gws`
21+
/// on macOS) for backward compatibility with existing installs.
22+
///
23+
/// In tests, the `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` environment variable overrides the default.
24+
pub fn config_dir() -> PathBuf {
25+
#[cfg(test)]
26+
if let Ok(dir) = std::env::var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR") {
27+
return PathBuf::from(dir);
28+
}
29+
30+
// Use ~/.config/gws on all platforms for a consistent, user-friendly path.
31+
let primary = dirs::home_dir()
32+
.unwrap_or_else(|| PathBuf::from("."))
33+
.join(".config")
34+
.join("gws");
35+
if primary.exists() {
36+
return primary;
37+
}
38+
39+
// Backward compat: fall back to OS-specific config dir for existing installs
40+
// (e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows).
41+
let legacy = dirs::config_dir()
42+
.unwrap_or_else(|| PathBuf::from("."))
43+
.join("gws");
44+
if legacy.exists() {
45+
return legacy;
46+
}
47+
48+
primary
49+
}

src/credential_store.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> {
4545
.or_else(|_| std::env::var("USERNAME"))
4646
.unwrap_or_else(|_| "unknown-user".to_string());
4747

48-
let key_file = crate::auth_commands::config_dir().join(".encryption_key");
48+
let key_file = crate::config::config_dir().join(".encryption_key");
4949

5050
let entry = Entry::new("gws-cli", &username);
5151

@@ -218,7 +218,7 @@ pub fn decrypt(data: &[u8]) -> anyhow::Result<Vec<u8>> {
218218

219219
/// Returns the path for encrypted credentials.
220220
pub fn encrypted_credentials_path() -> PathBuf {
221-
crate::auth_commands::config_dir().join("credentials.enc")
221+
crate::config::config_dir().join("credentials.enc")
222222
}
223223

224224
/// Saves credentials JSON to an encrypted file.

src/discovery.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ pub async fn fetch_discovery_document(
195195
let version =
196196
crate::validate::validate_api_identifier(version).map_err(|e| anyhow::anyhow!("{e}"))?;
197197

198-
let cache_dir = crate::auth_commands::config_dir().join("cache");
198+
let cache_dir = crate::config::config_dir().join("cache");
199199
std::fs::create_dir_all(&cache_dir)?;
200200

201201
let cache_file = cache_dir.join(format!("{service}_{version}.json"));

src/executor.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ async fn handle_json_response(
211211
body_text: &str,
212212
pagination: &PaginationConfig,
213213
sanitize_template: Option<&str>,
214-
sanitize_mode: &crate::helpers::modelarmor::SanitizeMode,
214+
sanitize_mode: &crate::sanitize::SanitizeMode,
215215
output_format: &crate::formatter::OutputFormat,
216216
pages_fetched: &mut u32,
217217
page_token: &mut Option<String>,
@@ -224,15 +224,14 @@ async fn handle_json_response(
224224
// Run Model Armor sanitization if --sanitize is enabled
225225
if let Some(template) = sanitize_template {
226226
let text_to_check = serde_json::to_string(&json_val).unwrap_or_default();
227-
match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await {
227+
match crate::sanitize::sanitize_text(template, &text_to_check).await {
228228
Ok(result) => {
229229
let is_match = result.filter_match_state == "MATCH_FOUND";
230230
if is_match {
231231
eprintln!("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)");
232232
}
233233

234-
if is_match && *sanitize_mode == crate::helpers::modelarmor::SanitizeMode::Block
235-
{
234+
if is_match && *sanitize_mode == crate::sanitize::SanitizeMode::Block {
236235
let blocked = serde_json::json!({
237236
"error": "Content blocked by Model Armor",
238237
"sanitizationResult": serde_json::to_value(&result).unwrap_or_default(),
@@ -370,7 +369,7 @@ pub async fn execute_method(
370369
dry_run: bool,
371370
pagination: &PaginationConfig,
372371
sanitize_template: Option<&str>,
373-
sanitize_mode: &crate::helpers::modelarmor::SanitizeMode,
372+
sanitize_mode: &crate::sanitize::SanitizeMode,
374373
output_format: &crate::formatter::OutputFormat,
375374
capture_output: bool,
376375
) -> Result<Option<Value>, GwsError> {
@@ -1656,7 +1655,7 @@ async fn test_execute_method_dry_run() {
16561655
let params_json = r#"{"fileId": "123"}"#;
16571656
let body_json = r#"{"name": "test.txt"}"#;
16581657

1659-
let sanitize_mode = crate::helpers::modelarmor::SanitizeMode::Warn;
1658+
let sanitize_mode = crate::sanitize::SanitizeMode::Warn;
16601659
let pagination = PaginationConfig::default();
16611660

16621661
let result = execute_method(
@@ -1701,7 +1700,7 @@ async fn test_execute_method_missing_path_param() {
17011700
..Default::default()
17021701
};
17031702

1704-
let sanitize_mode = crate::helpers::modelarmor::SanitizeMode::Warn;
1703+
let sanitize_mode = crate::sanitize::SanitizeMode::Warn;
17051704
let result = execute_method(
17061705
&doc,
17071706
&method,

src/helpers/modelarmor.rs

Lines changed: 17 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -18,65 +18,19 @@ use crate::discovery::RestDescription;
1818
use crate::error::GwsError;
1919
use anyhow::Context;
2020
use clap::{Arg, ArgMatches, Command};
21-
use serde::{Deserialize, Serialize};
2221
use serde_json::json;
2322
use std::future::Future;
2423
use std::pin::Pin;
2524

26-
/// Result of a Model Armor sanitization check.
27-
#[derive(Debug, Clone, Serialize, Deserialize)]
28-
#[serde(rename_all = "camelCase")]
29-
pub struct SanitizationResult {
30-
/// The overall state of the match (e.g., "MATCH_FOUND", "NO_MATCH_FOUND").
31-
pub filter_match_state: String,
32-
/// Detailed results from specific filters (PI, Jailbreak, etc.).
33-
#[serde(default)]
34-
pub filter_results: serde_json::Value,
35-
/// The final decision based on the policy (e.g., "BLOCK", "ALLOW").
36-
#[serde(default)]
37-
pub invocation_result: String,
38-
}
39-
40-
/// Controls behavior when sanitization finds a match.
41-
#[derive(Debug, Clone, PartialEq)]
42-
pub enum SanitizeMode {
43-
/// Log warning to stderr, annotate output with _sanitization field
44-
Warn,
45-
/// Suppress response output, exit non-zero
46-
Block,
47-
}
25+
// Re-export sanitization types from the standalone module so existing
26+
// `helpers::modelarmor::` paths continue to compile.
27+
pub use crate::sanitize::{
28+
sanitize_text, SanitizationResult, SanitizeConfig, SanitizeMode, CLOUD_PLATFORM_SCOPE,
29+
};
4830

49-
/// Configuration for Model Armor sanitization, threaded through the CLI.
50-
#[derive(Debug, Clone)]
51-
pub struct SanitizeConfig {
52-
pub template: Option<String>,
53-
pub mode: SanitizeMode,
54-
}
55-
56-
impl Default for SanitizeConfig {
57-
/// Provides default values for `SanitizeConfig`.
58-
///
59-
/// By default, no template is set (sanitization disabled) and the mode is `Warn`.
60-
fn default() -> Self {
61-
Self {
62-
template: None,
63-
mode: SanitizeMode::Warn,
64-
}
65-
}
66-
}
67-
68-
impl SanitizeMode {
69-
/// Parses a string into a `SanitizeMode`.
70-
///
71-
/// * "block" (case-insensitive) -> `Block`
72-
/// * Any other value -> `Warn` (safe default)
73-
pub fn from_str(s: &str) -> Self {
74-
match s.to_lowercase().as_str() {
75-
"block" => SanitizeMode::Block,
76-
_ => SanitizeMode::Warn,
77-
}
78-
}
79-
}
31+
// Re-export for tests in this module
32+
#[cfg(test)]
33+
pub(crate) use crate::sanitize::{build_sanitize_request_data, parse_sanitize_response};
8034

8135
pub struct ModelArmorHelper;
8236

@@ -243,42 +197,6 @@ TIPS:
243197
}
244198
}
245199

246-
pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
247-
248-
/// Sanitize text through a Model Armor template and return the result.
249-
/// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE
250-
pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationResult, GwsError> {
251-
let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?;
252-
253-
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
254-
.await
255-
.context("Failed to get auth token for Model Armor")?;
256-
257-
let client = crate::client::build_client()?;
258-
let resp = client
259-
.post(&url)
260-
.header("Authorization", format!("Bearer {token}"))
261-
.header("Content-Type", "application/json")
262-
.body(body)
263-
.send()
264-
.await
265-
.context("Model Armor request failed")?;
266-
267-
let status = resp.status();
268-
let resp_text = resp
269-
.text()
270-
.await
271-
.context("Failed to read Model Armor response")?;
272-
273-
if !status.is_success() {
274-
return Err(GwsError::Other(anyhow::anyhow!(
275-
"Model Armor API returned status {status}: {resp_text}"
276-
)));
277-
}
278-
279-
parse_sanitize_response(&resp_text)
280-
}
281-
282200
/// Make a POST request to Model Armor's regional API endpoint.
283201
async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> {
284202
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
@@ -459,23 +377,23 @@ mod tests {
459377

460378
#[test]
461379
fn test_sanitize_mode_from_str_warn() {
462-
assert_eq!(SanitizeMode::from_str("warn"), SanitizeMode::Warn);
463-
assert_eq!(SanitizeMode::from_str("WARN"), SanitizeMode::Warn);
464-
assert_eq!(SanitizeMode::from_str("Warn"), SanitizeMode::Warn);
380+
assert_eq!(SanitizeMode::from("warn"), SanitizeMode::Warn);
381+
assert_eq!(SanitizeMode::from("WARN"), SanitizeMode::Warn);
382+
assert_eq!(SanitizeMode::from("Warn"), SanitizeMode::Warn);
465383
}
466384

467385
#[test]
468386
fn test_sanitize_mode_from_str_block() {
469-
assert_eq!(SanitizeMode::from_str("block"), SanitizeMode::Block);
470-
assert_eq!(SanitizeMode::from_str("BLOCK"), SanitizeMode::Block);
471-
assert_eq!(SanitizeMode::from_str("Block"), SanitizeMode::Block);
387+
assert_eq!(SanitizeMode::from("block"), SanitizeMode::Block);
388+
assert_eq!(SanitizeMode::from("BLOCK"), SanitizeMode::Block);
389+
assert_eq!(SanitizeMode::from("Block"), SanitizeMode::Block);
472390
}
473391

474392
#[test]
475393
fn test_sanitize_mode_from_str_unknown_defaults_to_warn() {
476-
assert_eq!(SanitizeMode::from_str(""), SanitizeMode::Warn);
477-
assert_eq!(SanitizeMode::from_str("invalid"), SanitizeMode::Warn);
478-
assert_eq!(SanitizeMode::from_str("stop"), SanitizeMode::Warn);
394+
assert_eq!(SanitizeMode::from(""), SanitizeMode::Warn);
395+
assert_eq!(SanitizeMode::from("invalid"), SanitizeMode::Warn);
396+
assert_eq!(SanitizeMode::from("stop"), SanitizeMode::Warn);
479397
}
480398

481399
#[test]
@@ -565,47 +483,6 @@ mod tests {
565483
}
566484
}
567485

568-
pub fn build_sanitize_request_data(
569-
template: &str,
570-
text: &str,
571-
method: &str,
572-
) -> Result<(String, String), GwsError> {
573-
let location = extract_location(template).ok_or_else(|| {
574-
GwsError::Validation(
575-
"Cannot extract location from --sanitize template. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
576-
)
577-
})?;
578-
579-
let base = regional_base_url(location);
580-
let url = format!("{base}/{template}:{method}");
581-
582-
// Identify data field based on method
583-
let data_field = if method == "sanitizeUserPrompt" {
584-
"userPromptData"
585-
} else {
586-
"modelResponseData"
587-
};
588-
589-
let body = json!({data_field: {"text": text}}).to_string();
590-
Ok((body, url))
591-
}
592-
593-
pub fn parse_sanitize_response(resp_text: &str) -> Result<SanitizationResult, GwsError> {
594-
// Parse the response to extract sanitizationResult
595-
let parsed: serde_json::Value =
596-
serde_json::from_str(resp_text).context("Failed to parse Model Armor response")?;
597-
598-
let result = parsed.get("sanitizationResult").ok_or_else(|| {
599-
GwsError::Other(anyhow::anyhow!(
600-
"No sanitizationResult in Model Armor response"
601-
))
602-
})?;
603-
604-
let res =
605-
serde_json::from_value(result.clone()).context("Failed to parse sanitization result")?;
606-
Ok(res)
607-
}
608-
609486
fn parse_sanitize_args(matches: &ArgMatches, data_field: &str) -> Result<String, GwsError> {
610487
if let Some(json_str) = matches.get_one::<String>("json") {
611488
Ok(json_str.clone())

0 commit comments

Comments
 (0)