1use crate::core::auth::GoogleAuth;
12use crate::discovery::{self, Method};
13use crate::output::{OutputFormat, print_output};
14use anyhow::{Result, bail};
15use clap::ArgMatches;
16use serde_json::{Map, Value};
17
18use crate::commands::api::{self, call_method};
19use crate::commands::generated::GENERATED_AT;
20use crate::commands::safety::SafetyProfile;
21
22const RESERVED_FLAG_IDS: [&str; 5] = ["params", "body-file", "query", "dry-run", "format"];
27
28pub(crate) fn flag_id(name: &str) -> String {
35 let kebab = kebab_case(name);
36 if RESERVED_FLAG_IDS.contains(&kebab.as_str()) || kebab == "help" {
37 format!("param-{kebab}")
38 } else {
39 kebab
40 }
41}
42
43pub(crate) fn kebab_case(name: &str) -> String {
47 let mut out = String::with_capacity(name.len() + 4);
48 let mut prev: Option<char> = None;
49 for c in name.chars() {
50 if c.is_ascii_uppercase() {
51 if prev.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) {
54 out.push('-');
55 }
56 out.push(c.to_ascii_lowercase());
57 } else if c.is_ascii_alphanumeric() {
58 out.push(c);
59 } else if !out.is_empty() && !out.ends_with('-') {
60 out.push('-');
62 }
63 prev = Some(c);
64 }
65 if out.ends_with('-') {
66 out.pop();
67 }
68 out
69}
70
71pub async fn dispatch(matches: &ArgMatches, auth: Option<&GoogleAuth>) -> Result<()> {
79 dispatch_with_profile(matches, auth, &SafetyProfile::PERMISSIVE).await
80}
81
82pub async fn dispatch_with_profile(
85 matches: &ArgMatches,
86 auth: Option<&GoogleAuth>,
87 profile: &SafetyProfile,
88) -> Result<()> {
89 let mut cursor = matches;
97 let mut segments: Vec<&str> = Vec::new();
98 while let Some((name, sub)) = cursor.subcommand() {
99 segments.push(name);
100 cursor = sub;
101 }
102 let Some(leaf) = segments.last().copied() else {
103 bail!("no method matched; expected <service> <resource...> <method>");
104 };
105 let id = if leaf.contains('.') {
106 leaf.to_owned()
107 } else {
108 segments.join(".")
109 };
110
111 let (service, method) = discovery::resolve(&id).map_err(anyhow::Error::msg)?;
112
113 if let Err(message) = profile.check(&service.name, method) {
115 bail!("{message}");
116 }
117
118 let format = cursor
119 .get_one::<OutputFormat>("format")
120 .cloned()
121 .unwrap_or_default();
122
123 warn_unknown_flags(method, cursor);
124 let params = collect_params(method, cursor)?;
125
126 let payload = call_method(
127 auth,
128 service,
129 method,
130 params,
131 api::CallOptions {
132 body_file: cursor.get_one::<String>("body-file").cloned(),
133 verb_override: None,
136 query_extras: query_extras(cursor),
137 dry_run: cursor.get_flag("dry-run"),
138 },
139 )
140 .await?;
141 print_output(&payload, format)?;
142 Ok(())
143}
144
145fn query_extras(leaf: &ArgMatches) -> Vec<String> {
146 leaf.get_many::<String>("query")
147 .map(|values| values.cloned().collect())
148 .unwrap_or_default()
149}
150
151fn collect_params(method: &Method, leaf: &ArgMatches) -> Result<Map<String, Value>> {
155 let mut params = api::parse_params(leaf.get_one::<String>("params").map(String::as_str), &[])?;
156 for (name, value) in typed_params(method, leaf) {
157 params.insert(name, value);
158 }
159 Ok(params)
160}
161
162fn typed_params(method: &Method, leaf: &ArgMatches) -> Vec<(String, Value)> {
170 let mut out = Vec::new();
171 for param in &method.parameters {
172 let id = flag_id(¶m.name);
173 let Ok(Some(raw)) = leaf.try_get_raw(&id) else {
176 continue;
177 };
178 let kind = param.kind.as_str();
179
180 let texts: Vec<String> = raw.map(|os| os.to_string_lossy().into_owned()).collect();
185 if kind == "boolean" && !param.repeated {
186 if texts.iter().any(|t| t.eq_ignore_ascii_case("true")) {
187 out.push((param.name.clone(), Value::Bool(true)));
188 }
189 continue;
190 }
191
192 let values: Vec<Value> = texts
193 .into_iter()
194 .map(|text| {
195 if kind == "integer"
196 && let Ok(number) = text.parse::<i64>()
197 {
198 Value::Number(number.into())
199 } else if kind == "boolean" {
200 Value::Bool(text.eq_ignore_ascii_case("true"))
201 } else {
202 Value::String(text)
203 }
204 })
205 .collect();
206 if values.is_empty() {
207 continue;
208 }
209 if param.repeated {
210 out.push((param.name.clone(), Value::Array(values)));
211 } else {
212 out.push((
213 param.name.clone(),
214 values.into_iter().next().unwrap_or(Value::Null),
215 ));
216 }
217 }
218 out
219}
220
221fn warn_unknown_flags(method: &Method, leaf: &ArgMatches) {
225 let mut known: Vec<String> = RESERVED_FLAG_IDS
226 .iter()
227 .map(ToString::to_string)
228 .collect::<Vec<_>>();
229 known.push("help".to_owned());
230 known.extend(method.parameters.iter().map(|p| flag_id(&p.name)));
231 for id in leaf.ids() {
232 if !known.iter().any(|k| k == id.as_str()) {
233 eprintln!(
234 "note: `--{id}` is not a parameter of {} in the current Discovery index \
235 (command tree generated at {GENERATED_AT}; a refreshed cache may be newer). \
236 Pass it through --params if the API still accepts it.",
237 method.id
238 );
239 }
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use serde_json::json;
247
248 #[test]
249 fn flag_ids_match_the_generator_rules() {
250 assert_eq!(flag_id("userId"), "user-id");
252 assert_eq!(flag_id("maxResults"), "max-results");
253 assert_eq!(flag_id("labelIds"), "label-ids");
254 assert_eq!(flag_id("includeSpamTrash"), "include-spam-trash");
255 assert_eq!(
256 flag_id("requestMask.includeField"),
257 "request-mask-include-field"
258 );
259 assert_eq!(flag_id("useAdminAccess"), "use-admin-access");
260 assert_eq!(flag_id("format"), "param-format");
262 assert_eq!(flag_id("query"), "param-query");
263 assert_eq!(flag_id("help"), "param-help");
264 assert_eq!(kebab_case("$.xgafv"), "xgafv");
266 assert_eq!(kebab_case("getThreadReadState"), "get-thread-read-state");
267 }
268
269 #[test]
270 fn the_tree_walk_yields_the_full_dotted_id() {
271 let root = crate::cli::root_command();
272 let matches = root
273 .try_get_matches_from([
274 "grr",
275 "gmail",
276 "users",
277 "messages",
278 "list",
279 "--user-id",
280 "me",
281 ])
282 .expect("valid invocation");
283 let mut cursor = &matches;
284 let mut segments = Vec::new();
285 while let Some((name, sub)) = cursor.subcommand() {
286 segments.push(name);
287 cursor = sub;
288 }
289 let leaf = segments.last().copied().unwrap();
290 let id = if leaf.contains('.') {
291 leaf.to_owned()
292 } else {
293 segments.join(".")
294 };
295 assert_eq!(id, "gmail.users.messages.list");
296 assert!(discovery::resolve(&id).is_ok());
297 }
298
299 #[test]
300 fn typed_flags_round_trip_under_their_original_names() {
301 let root = crate::cli::root_command();
302 let matches = root
303 .try_get_matches_from([
304 "grr",
305 "gmail",
306 "users",
307 "messages",
308 "list",
309 "--user-id",
310 "me",
311 "--max-results",
312 "10",
313 "--include-spam-trash",
314 "--label-ids",
315 "INBOX",
316 "--label-ids",
317 "UNREAD",
318 "--q",
319 "is:unread",
320 ])
321 .expect("valid invocation");
322 let leaf = deepest(&matches);
323 let (_, method) = discovery::resolve("gmail.users.messages.list").unwrap();
324 let params = collect_params(method, leaf).unwrap();
325
326 assert_eq!(params.get("userId"), Some(&json!("me")));
327 assert_eq!(params.get("maxResults"), Some(&json!(10)));
328 assert_eq!(params.get("includeSpamTrash"), Some(&json!(true)));
329 assert_eq!(params.get("q"), Some(&json!("is:unread")));
330 assert_eq!(params.get("labelIds"), Some(&json!(["INBOX", "UNREAD"])));
331 }
332
333 #[test]
334 fn typed_flags_win_over_params_json() {
335 let root = crate::cli::root_command();
336 let matches = root
337 .try_get_matches_from([
338 "grr",
339 "gmail",
340 "users",
341 "messages",
342 "list",
343 "--user-id",
344 "me",
345 "--params",
346 r#"{"userId":"someone-else","q":"is:unread"}"#,
347 ])
348 .expect("valid invocation");
349 let leaf = deepest(&matches);
350 let (_, method) = discovery::resolve("gmail.users.messages.list").unwrap();
351 let params = collect_params(method, leaf).unwrap();
352 assert_eq!(params.get("userId"), Some(&json!("me")));
353 assert_eq!(params.get("q"), Some(&json!("is:unread")));
354 }
355
356 #[test]
357 fn a_service_with_no_curated_commands_is_reachable() {
358 let root = crate::cli::root_command();
361 let matches = root
362 .try_get_matches_from(["grr", "tasks", "tasklists", "list", "--dry-run"])
363 .expect("valid invocation");
364 let leaf = deepest(&matches);
365 let (_, method) = discovery::resolve("tasks.tasklists.list").unwrap();
366 let params = collect_params(method, leaf).unwrap();
367 assert_eq!(params.get("userId"), None);
368 }
369
370 #[test]
371 fn generated_and_api_call_paths_plan_identical_dry_runs() {
372 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
376
377 let api_params = api::parse_params(
379 Some(r#"{"userId":"me","q":"is:unread","labelIds":["INBOX","UNREAD"]}"#),
380 &[],
381 )
382 .unwrap();
383 let api_plan = api::plan_request(service, method, &api_params, None, None, &[]).unwrap();
384 let api_payload = api::dry_run_payload(method, &api_plan);
385
386 let root = crate::cli::root_command();
388 let matches = root
389 .try_get_matches_from([
390 "grr",
391 "gmail",
392 "users",
393 "messages",
394 "list",
395 "--user-id",
396 "me",
397 "--q",
398 "is:unread",
399 "--label-ids",
400 "INBOX",
401 "--label-ids",
402 "UNREAD",
403 ])
404 .expect("valid invocation");
405 let leaf = deepest(&matches);
406 let gen_params = collect_params(method, leaf).unwrap();
407 let gen_plan = api::plan_request(service, method, &gen_params, None, None, &[]).unwrap();
408 let gen_payload = api::dry_run_payload(method, &gen_plan);
409
410 assert_eq!(api_plan.verb, gen_plan.verb);
411 assert_eq!(api_plan.url, gen_plan.url);
412 assert_eq!(api_plan.body, gen_plan.body);
413 assert_eq!(
414 serde_json::to_string(&api_payload).unwrap(),
415 serde_json::to_string(&gen_payload).unwrap()
416 );
417 }
418
419 #[test]
420 fn a_reserved_flag_name_is_reachable_under_its_param_alias() {
421 let root = crate::cli::root_command();
425 let matches = root
426 .try_get_matches_from([
427 "grr",
428 "gmail",
429 "users",
430 "messages",
431 "get",
432 "--user-id",
433 "me",
434 "--id",
435 "m1",
436 "--param-format",
437 "raw",
438 ])
439 .expect("valid invocation");
440 let leaf = deepest(&matches);
441 let (_, method) = discovery::resolve("gmail.users.messages.get").unwrap();
442 let params = collect_params(method, leaf).unwrap();
443 assert_eq!(params.get("format"), Some(&json!("raw")));
444 }
445
446 async fn test_auth() -> GoogleAuth {
447 let config = crate::core::config::OAuthConfig {
448 client_id: "test-id.apps.googleusercontent.com".into(),
449 client_secret: None,
450 ..Default::default()
451 };
452 let storage = crate::core::auth::TokenStorage {
453 access_token: "test-token".into(),
454 refresh_token: None,
455 expires_at: u64::MAX,
456 token_type: "Bearer".into(),
457 scope: String::new(),
458 };
459 GoogleAuth::with_token(config, storage).await.unwrap()
460 }
461
462 #[tokio::test]
463 async fn dispatch_with_profile_refuses_a_write() {
464 let root = crate::cli::root_command();
467 let matches = root
468 .try_get_matches_from([
469 "grr",
470 "gmail",
471 "users",
472 "messages",
473 "send",
474 "--user-id",
475 "me",
476 "--dry-run",
477 ])
478 .expect("valid invocation");
479 let auth = test_auth().await;
480 let err = dispatch_with_profile(&matches, Some(&auth), &SafetyProfile::readonly())
481 .await
482 .unwrap_err()
483 .to_string();
484 assert!(err.contains("POST"), "{err}");
485 assert!(err.contains("--readonly"), "{err}");
486 }
487
488 #[tokio::test]
489 async fn dispatch_with_profile_refuses_a_denied_service_only() {
490 let root = crate::cli::root_command();
491 let matches = root
492 .try_get_matches_from([
493 "grr",
494 "gmail",
495 "users",
496 "messages",
497 "list",
498 "--user-id",
499 "me",
500 "--dry-run",
501 ])
502 .expect("valid invocation");
503 let auth = test_auth().await;
504 let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
505 let err = dispatch_with_profile(&matches, Some(&auth), &profile)
506 .await
507 .unwrap_err()
508 .to_string();
509 assert!(err.contains("--deny-service gmail"), "{err}");
510
511 let root = crate::cli::root_command();
513 let matches = root
514 .try_get_matches_from(["grr", "tasks", "tasklists", "list", "--dry-run"])
515 .expect("valid invocation");
516 assert!(
517 dispatch_with_profile(&matches, Some(&auth), &profile)
518 .await
519 .is_ok()
520 );
521 }
522
523 #[tokio::test]
524 async fn plain_dispatch_stays_permissive() {
525 let root = crate::cli::root_command();
527 let matches = root
528 .try_get_matches_from([
529 "grr",
530 "gmail",
531 "users",
532 "messages",
533 "list",
534 "--user-id",
535 "me",
536 "--dry-run",
537 ])
538 .expect("valid invocation");
539 let auth = test_auth().await;
540 assert!(dispatch(&matches, Some(&auth)).await.is_ok());
541 }
542
543 fn deepest(matches: &ArgMatches) -> &ArgMatches {
544 let mut cursor = matches;
545 while let Some((_, sub)) = cursor.subcommand() {
546 cursor = sub;
547 }
548 cursor
549 }
550}