use crate::core::auth::GoogleAuth;
use crate::discovery::{self, Method};
use crate::output::{OutputFormat, print_output};
use anyhow::{Result, bail};
use clap::ArgMatches;
use serde_json::{Map, Value};
use crate::commands::api::{self, call_method};
use crate::commands::generated::GENERATED_AT;
use crate::commands::safety::SafetyProfile;
const RESERVED_FLAG_IDS: [&str; 5] = ["params", "body-file", "query", "dry-run", "format"];
pub(crate) fn flag_id(name: &str) -> String {
let kebab = kebab_case(name);
if RESERVED_FLAG_IDS.contains(&kebab.as_str()) || kebab == "help" {
format!("param-{kebab}")
} else {
kebab
}
}
pub(crate) fn kebab_case(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 4);
let mut prev: Option<char> = None;
for c in name.chars() {
if c.is_ascii_uppercase() {
if prev.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) {
out.push('-');
}
out.push(c.to_ascii_lowercase());
} else if c.is_ascii_alphanumeric() {
out.push(c);
} else if !out.is_empty() && !out.ends_with('-') {
out.push('-');
}
prev = Some(c);
}
if out.ends_with('-') {
out.pop();
}
out
}
pub async fn dispatch(matches: &ArgMatches, auth: Option<&GoogleAuth>) -> Result<()> {
dispatch_with_profile(matches, auth, &SafetyProfile::PERMISSIVE).await
}
pub async fn dispatch_with_profile(
matches: &ArgMatches,
auth: Option<&GoogleAuth>,
profile: &SafetyProfile,
) -> Result<()> {
let mut cursor = matches;
let mut segments: Vec<&str> = Vec::new();
while let Some((name, sub)) = cursor.subcommand() {
segments.push(name);
cursor = sub;
}
let Some(leaf) = segments.last().copied() else {
bail!("no method matched; expected <service> <resource...> <method>");
};
let id = if leaf.contains('.') {
leaf.to_owned()
} else {
segments.join(".")
};
let (service, method) = discovery::resolve(&id).map_err(anyhow::Error::msg)?;
if let Err(message) = profile.check(&service.name, method) {
bail!("{message}");
}
let format = cursor
.get_one::<OutputFormat>("format")
.cloned()
.unwrap_or_default();
warn_unknown_flags(method, cursor);
let params = collect_params(method, cursor)?;
let payload = call_method(
auth,
service,
method,
params,
api::CallOptions {
body_file: cursor.get_one::<String>("body-file").cloned(),
verb_override: None,
query_extras: query_extras(cursor),
dry_run: cursor.get_flag("dry-run"),
},
)
.await?;
print_output(&payload, format)?;
Ok(())
}
fn query_extras(leaf: &ArgMatches) -> Vec<String> {
leaf.get_many::<String>("query")
.map(|values| values.cloned().collect())
.unwrap_or_default()
}
fn collect_params(method: &Method, leaf: &ArgMatches) -> Result<Map<String, Value>> {
let mut params = api::parse_params(leaf.get_one::<String>("params").map(String::as_str), &[])?;
for (name, value) in typed_params(method, leaf) {
params.insert(name, value);
}
Ok(params)
}
fn typed_params(method: &Method, leaf: &ArgMatches) -> Vec<(String, Value)> {
let mut out = Vec::new();
for param in &method.parameters {
let id = flag_id(¶m.name);
let Ok(Some(raw)) = leaf.try_get_raw(&id) else {
continue;
};
let kind = param.kind.as_str();
let texts: Vec<String> = raw.map(|os| os.to_string_lossy().into_owned()).collect();
if kind == "boolean" && !param.repeated {
if texts.iter().any(|t| t.eq_ignore_ascii_case("true")) {
out.push((param.name.clone(), Value::Bool(true)));
}
continue;
}
let values: Vec<Value> = texts
.into_iter()
.map(|text| {
if kind == "integer"
&& let Ok(number) = text.parse::<i64>()
{
Value::Number(number.into())
} else if kind == "boolean" {
Value::Bool(text.eq_ignore_ascii_case("true"))
} else {
Value::String(text)
}
})
.collect();
if values.is_empty() {
continue;
}
if param.repeated {
out.push((param.name.clone(), Value::Array(values)));
} else {
out.push((
param.name.clone(),
values.into_iter().next().unwrap_or(Value::Null),
));
}
}
out
}
fn warn_unknown_flags(method: &Method, leaf: &ArgMatches) {
let mut known: Vec<String> = RESERVED_FLAG_IDS
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
known.push("help".to_owned());
known.extend(method.parameters.iter().map(|p| flag_id(&p.name)));
for id in leaf.ids() {
if !known.iter().any(|k| k == id.as_str()) {
eprintln!(
"note: `--{id}` is not a parameter of {} in the current Discovery index \
(command tree generated at {GENERATED_AT}; a refreshed cache may be newer). \
Pass it through --params if the API still accepts it.",
method.id
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn flag_ids_match_the_generator_rules() {
assert_eq!(flag_id("userId"), "user-id");
assert_eq!(flag_id("maxResults"), "max-results");
assert_eq!(flag_id("labelIds"), "label-ids");
assert_eq!(flag_id("includeSpamTrash"), "include-spam-trash");
assert_eq!(
flag_id("requestMask.includeField"),
"request-mask-include-field"
);
assert_eq!(flag_id("useAdminAccess"), "use-admin-access");
assert_eq!(flag_id("format"), "param-format");
assert_eq!(flag_id("query"), "param-query");
assert_eq!(flag_id("help"), "param-help");
assert_eq!(kebab_case("$.xgafv"), "xgafv");
assert_eq!(kebab_case("getThreadReadState"), "get-thread-read-state");
}
#[test]
fn the_tree_walk_yields_the_full_dotted_id() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"list",
"--user-id",
"me",
])
.expect("valid invocation");
let mut cursor = &matches;
let mut segments = Vec::new();
while let Some((name, sub)) = cursor.subcommand() {
segments.push(name);
cursor = sub;
}
let leaf = segments.last().copied().unwrap();
let id = if leaf.contains('.') {
leaf.to_owned()
} else {
segments.join(".")
};
assert_eq!(id, "gmail.users.messages.list");
assert!(discovery::resolve(&id).is_ok());
}
#[test]
fn typed_flags_round_trip_under_their_original_names() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"list",
"--user-id",
"me",
"--max-results",
"10",
"--include-spam-trash",
"--label-ids",
"INBOX",
"--label-ids",
"UNREAD",
"--q",
"is:unread",
])
.expect("valid invocation");
let leaf = deepest(&matches);
let (_, method) = discovery::resolve("gmail.users.messages.list").unwrap();
let params = collect_params(method, leaf).unwrap();
assert_eq!(params.get("userId"), Some(&json!("me")));
assert_eq!(params.get("maxResults"), Some(&json!(10)));
assert_eq!(params.get("includeSpamTrash"), Some(&json!(true)));
assert_eq!(params.get("q"), Some(&json!("is:unread")));
assert_eq!(params.get("labelIds"), Some(&json!(["INBOX", "UNREAD"])));
}
#[test]
fn typed_flags_win_over_params_json() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"list",
"--user-id",
"me",
"--params",
r#"{"userId":"someone-else","q":"is:unread"}"#,
])
.expect("valid invocation");
let leaf = deepest(&matches);
let (_, method) = discovery::resolve("gmail.users.messages.list").unwrap();
let params = collect_params(method, leaf).unwrap();
assert_eq!(params.get("userId"), Some(&json!("me")));
assert_eq!(params.get("q"), Some(&json!("is:unread")));
}
#[test]
fn a_service_with_no_curated_commands_is_reachable() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from(["grr", "tasks", "tasklists", "list", "--dry-run"])
.expect("valid invocation");
let leaf = deepest(&matches);
let (_, method) = discovery::resolve("tasks.tasklists.list").unwrap();
let params = collect_params(method, leaf).unwrap();
assert_eq!(params.get("userId"), None);
}
#[test]
fn generated_and_api_call_paths_plan_identical_dry_runs() {
let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
let api_params = api::parse_params(
Some(r#"{"userId":"me","q":"is:unread","labelIds":["INBOX","UNREAD"]}"#),
&[],
)
.unwrap();
let api_plan = api::plan_request(service, method, &api_params, None, None, &[]).unwrap();
let api_payload = api::dry_run_payload(method, &api_plan);
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"list",
"--user-id",
"me",
"--q",
"is:unread",
"--label-ids",
"INBOX",
"--label-ids",
"UNREAD",
])
.expect("valid invocation");
let leaf = deepest(&matches);
let gen_params = collect_params(method, leaf).unwrap();
let gen_plan = api::plan_request(service, method, &gen_params, None, None, &[]).unwrap();
let gen_payload = api::dry_run_payload(method, &gen_plan);
assert_eq!(api_plan.verb, gen_plan.verb);
assert_eq!(api_plan.url, gen_plan.url);
assert_eq!(api_plan.body, gen_plan.body);
assert_eq!(
serde_json::to_string(&api_payload).unwrap(),
serde_json::to_string(&gen_payload).unwrap()
);
}
#[test]
fn a_reserved_flag_name_is_reachable_under_its_param_alias() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"get",
"--user-id",
"me",
"--id",
"m1",
"--param-format",
"raw",
])
.expect("valid invocation");
let leaf = deepest(&matches);
let (_, method) = discovery::resolve("gmail.users.messages.get").unwrap();
let params = collect_params(method, leaf).unwrap();
assert_eq!(params.get("format"), Some(&json!("raw")));
}
async fn test_auth() -> GoogleAuth {
let config = crate::core::config::OAuthConfig {
client_id: "test-id.apps.googleusercontent.com".into(),
client_secret: None,
..Default::default()
};
let storage = crate::core::auth::TokenStorage {
access_token: "test-token".into(),
refresh_token: None,
expires_at: u64::MAX,
token_type: "Bearer".into(),
scope: String::new(),
};
GoogleAuth::with_token(config, storage).await.unwrap()
}
#[tokio::test]
async fn dispatch_with_profile_refuses_a_write() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"send",
"--user-id",
"me",
"--dry-run",
])
.expect("valid invocation");
let auth = test_auth().await;
let err = dispatch_with_profile(&matches, Some(&auth), &SafetyProfile::readonly())
.await
.unwrap_err()
.to_string();
assert!(err.contains("POST"), "{err}");
assert!(err.contains("--readonly"), "{err}");
}
#[tokio::test]
async fn dispatch_with_profile_refuses_a_denied_service_only() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"list",
"--user-id",
"me",
"--dry-run",
])
.expect("valid invocation");
let auth = test_auth().await;
let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
let err = dispatch_with_profile(&matches, Some(&auth), &profile)
.await
.unwrap_err()
.to_string();
assert!(err.contains("--deny-service gmail"), "{err}");
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from(["grr", "tasks", "tasklists", "list", "--dry-run"])
.expect("valid invocation");
assert!(
dispatch_with_profile(&matches, Some(&auth), &profile)
.await
.is_ok()
);
}
#[tokio::test]
async fn plain_dispatch_stays_permissive() {
let root = crate::cli::root_command();
let matches = root
.try_get_matches_from([
"grr",
"gmail",
"users",
"messages",
"list",
"--user-id",
"me",
"--dry-run",
])
.expect("valid invocation");
let auth = test_auth().await;
assert!(dispatch(&matches, Some(&auth)).await.is_ok());
}
fn deepest(matches: &ArgMatches) -> &ArgMatches {
let mut cursor = matches;
while let Some((_, sub)) = cursor.subcommand() {
cursor = sub;
}
cursor
}
}