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/// Entry point for every generated service subcommand under the permissive
72/// safety profile. `matches` is the ROOT `ArgMatches` (the one whose current
73/// subcommand is the service), so the full chain is walkable from here.
74///
75/// Production dispatch goes through [`dispatch_with_profile`] (wired in
76/// `cli.rs`), which applies the global `--readonly`/`--deny-*` flags; this
77/// profile-free wrapper is what the unit tests exercise.
78pub async fn dispatch(matches: &ArgMatches, auth: Option<&GoogleAuth>) -> Result<()> {
79    dispatch_with_profile(matches, auth, &SafetyProfile::PERMISSIVE).await
80}
81
82/// [`dispatch`] with a safety profile — the seam for `cli.rs`. The same
83/// gate the `grr api call` flags and the `grr mcp` server honor.
84pub async fn dispatch_with_profile(
85    matches: &ArgMatches,
86    auth: Option<&GoogleAuth>,
87    profile: &SafetyProfile,
88) -> Result<()> {
89    // Walk to the deepest subcommand. The generator guarantees a leaf's
90    // canonical clap name IS its full dotted method id
91    // (`gmail.users.messages.list`), so the deepest name is the id —
92    // dispatch never reconstructs paths. The join below only exists for
93    // a leaf reached by some future non-dotted name; since every
94    // intermediate subcommand is its verbatim id segment, the join is
95    // exact there too.
96    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    // The safety gate runs before anything is built or sent.
114    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            // The verb override is `grr api call`'s escape hatch; the tree
134            // speaks the Discovery document.
135            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
151/// Build the method's parameter map: `--params` JSON first, typed flags
152/// second — the merge order of `api::parse_params`, so a typed flag always
153/// wins on conflict.
154fn 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
162/// Typed flag values -> JSON values under their ORIGINAL Discovery names.
163///
164/// Values are read with `try_get_raw` (the pre-parser `OsString`s), never
165/// the typed getters: `discovery::resolve` may return a method from a
166/// refreshed cache that is newer than this build's generated tree, and a
167/// typed getter on a mismatched (or unknown-to-this-build) arg panics
168/// inside clap. Raw reads degrade to "flag not recognised" instead.
169fn 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(&param.name);
173        // Err = this build's tree does not define the flag (cache newer
174        // than the binary); Ok(None) = flag defined but not passed.
175        let Ok(Some(raw)) = leaf.try_get_raw(&id) else {
176            continue;
177        };
178        let kind = param.kind.as_str();
179
180        // SetTrue flags carry an injected "false" default when absent, so
181        // presence is not enough: only a genuinely passed flag ("true")
182        // becomes a parameter — an explicit false would change the request
183        // URL versus leaving the parameter off entirely.
184        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
221/// A flag the tree parsed but the resolved method does not know means the
222/// on-disk Discovery cache drifted from this build's generated tree. Its
223/// value would be silently dropped, so say so and point at --params.
224fn 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        // Pinned against `kebab()`/`flagId()` in scripts/generate-commands.mjs.
251        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        // Reserved escape-hatch names get the param- prefix.
261        assert_eq!(flag_id("format"), "param-format");
262        assert_eq!(flag_id("query"), "param-query");
263        assert_eq!(flag_id("help"), "param-help");
264        // Non-alphanumeric runs collapse; nothing usable is left dangling.
265        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        // Tasks had zero hand-written commands; the generated tree is its
359        // entire surface.
360        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        // The parity contract: the same method and parameters must produce
373        // byte-identical dry-run output whether they arrive through the
374        // generated tree (typed flags) or `grr api call` (--params JSON).
375        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
376
377        // `grr api call` route: --params JSON blob.
378        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        // Generated route: the real clap tree, flags parsed by clap.
387        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        // `format` on gmail.users.messages.get collides with the output
422        // --format escape hatch, so the generator exposes it as
423        // --param-format and dispatch must find it by that id.
424        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        // The gate fires before anything is built or sent, so the dummy
465        // auth handle is never used.
466        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        // Another service rides through untouched.
512        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        // The unwired default: the same dry-run the tree always returned.
526        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}