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