Skip to main content

grr_cli/commands/
gen_dispatch.rs

1//! Dispatch for the generated service tree.
2//!
3//! The tree itself (`src/commands/generated.rs`) is pure clap structure —
4//! generated from the Discovery index, no runtime logic. This module is the
5//! runtime half: walk the matched subcommand chain to the leaf, resolve the
6//! method id against the Discovery index, turn the typed flags back into a
7//! parameter map under their ORIGINAL camelCase names, and hand everything
8//! to the one shared call path (`api::call_method`) that `grr api call`
9//! also uses.
10
11use 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
22/// Escape-hatch arg ids present on every generated leaf (plus clap's own
23/// `help`). A Discovery parameter whose kebab-case name lands here is
24/// renamed `param-<kebab>` — the generator applies the same rule, and the
25/// tests pin the two together.
26const RESERVED_FLAG_IDS: [&str; 5] = ["params", "body-file", "query", "dry-run", "format"];
27
28/// Discovery parameter name -> generated flag id.
29///
30/// camelCase becomes kebab-case (`userId` -> `--user-id`), non-alphanumeric
31/// runs collapse to a dash (`requestMask.includeField` ->
32/// `--request-mask-include-field`), and reserved escape-hatch names are
33/// prefixed (`format` -> `--param-format`).
34pub(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
43/// The kebab half of [`flag_id`]. Mirrors `kebab()` in
44/// `scripts/generate-commands.mjs` exactly; a drift between the two would
45/// make typed flags silently vanish, so tests pin the pairs down.
46pub(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            // `getProfile` -> `get-profile`, but `ACLx` stays one word:
52            // only a lowercase/digit -> uppercase boundary is a split.
53            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            // collapse runs of specials (`.`, `$`, `_`, …) to one dash
61            out.push('-');
62        }
63        prev = Some(c);
64    }
65    if out.ends_with('-') {
66        out.pop();
67    }
68    out
69}
70
71/// Dispatch a generated service subcommand with a safety profile — the seam
72/// for `cli.rs` (the only production entry point). The same gate the
73/// `grr api call` flags and the `grr mcp` server honor.
74pub async fn dispatch_with_profile(
75    matches: &ArgMatches,
76    auth: Option<&GoogleAuth>,
77    profile: &SafetyProfile,
78) -> Result<()> {
79    // Walk to the deepest subcommand. The generator guarantees a leaf's
80    // canonical clap name IS its full dotted method id
81    // (`gmail.users.messages.list`), so the deepest name is the id —
82    // dispatch never reconstructs paths. The join below only exists for
83    // a leaf reached by some future non-dotted name; since every
84    // intermediate subcommand is its verbatim id segment, the join is
85    // exact there too.
86    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    // The safety gate runs before anything is built or sent.
104    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            // The verb override is `grr api call`'s escape hatch; the tree
124            // speaks the Discovery document.
125            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
141/// Build the method's parameter map: `--params` JSON first, typed flags
142/// second — the merge order of `api::parse_params`, so a typed flag always
143/// wins on conflict.
144fn 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
152/// Typed flag values -> JSON values under their ORIGINAL Discovery names.
153///
154/// Values are read with `try_get_raw` (the pre-parser `OsString`s), never
155/// the typed getters: `discovery::resolve` may return a method from a
156/// refreshed cache that is newer than this build's generated tree, and a
157/// typed getter on a mismatched (or unknown-to-this-build) arg panics
158/// inside clap. Raw reads degrade to "flag not recognised" instead.
159fn 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(&param.name);
163        // Err = this build's tree does not define the flag (cache newer
164        // than the binary); Ok(None) = flag defined but not passed.
165        let Ok(Some(raw)) = leaf.try_get_raw(&id) else {
166            continue;
167        };
168        let kind = param.kind.as_str();
169
170        // SetTrue flags carry an injected "false" default when absent, so
171        // presence is not enough: only a genuinely passed flag ("true")
172        // becomes a parameter — an explicit false would change the request
173        // URL versus leaving the parameter off entirely.
174        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
211/// A flag the tree parsed but the resolved method does not know means the
212/// on-disk Discovery cache drifted from this build's generated tree. Its
213/// value would be silently dropped, so say so and point at --params.
214fn 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        // Pinned against `kebab()`/`flagId()` in scripts/generate-commands.mjs.
241        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        // Reserved escape-hatch names get the param- prefix.
251        assert_eq!(flag_id("format"), "param-format");
252        assert_eq!(flag_id("query"), "param-query");
253        assert_eq!(flag_id("help"), "param-help");
254        // Non-alphanumeric runs collapse; nothing usable is left dangling.
255        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        // Tasks had zero hand-written commands; the generated tree is its
349        // entire surface.
350        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        // The parity contract: the same method and parameters must produce
363        // byte-identical dry-run output whether they arrive through the
364        // generated tree (typed flags) or `grr api call` (--params JSON).
365        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
366
367        // `grr api call` route: --params JSON blob.
368        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        // Generated route: the real clap tree, flags parsed by clap.
377        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        // `format` on gmail.users.messages.get collides with the output
412        // --format escape hatch, so the generator exposes it as
413        // --param-format and dispatch must find it by that id.
414        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        // The gate fires before anything is built or sent, so the dummy
455        // auth handle is never used.
456        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        // Another service rides through untouched.
502        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        // The default profile: the same dry-run the tree always returned.
516        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}