Skip to main content

grr_cli/
discovery.rs

1//! Discovery-driven API surface.
2//!
3//! Google's Discovery Service publishes a machine-readable description of
4//! every Workspace method: HTTP verb, path template, parameters and OAuth
5//! scopes. `scripts/fetch-discovery.mjs` distils those documents into a
6//! compact index (method id, verb, path, scopes, parameters) which this
7//! module embeds — 401 methods across 14 services in ~430 KiB, versus
8//! several megabytes for the raw documents.
9//!
10//! This is the escape hatch that makes the curated commands unnecessary for
11//! coverage: `grr api call <service>.<resource>.<method>` reaches every
12//! method Google publishes, including ones that did not exist when grr was
13//! written, with no code change and no release.
14//!
15//! Two properties matter more than the coverage:
16//!
17//! 1. **The index is the scope ceiling.** Every method carries its own scope
18//!    list — `grr api describe` reports it and a 403 names it — so whatever
19//!    Google publishes next is reachable the day the discovery PR lands. The
20//!    login requests only a small default set (`core::auth::login_scopes`),
21//!    not the union; a method outside the current grant answers 403 until
22//!    `grr auth login --service …` / `--all-scopes` widens it.
23//! 2. **Offline.** The index is compiled in, so `grr api list` and
24//!    `describe` work with no network and no cache directory.
25
26use serde::Deserialize;
27use std::collections::BTreeMap;
28use std::sync::OnceLock;
29
30/// `(filename, service name, api version, discovery url)` — the version is
31/// what callers need for display; the url is what `grr api refresh` fetches.
32const SERVICES: &[(&str, &str, &str, &str)] = &[
33    (
34        "gmail.json",
35        "gmail",
36        "v1",
37        "https://gmail.googleapis.com/$discovery/rest?version=v1",
38    ),
39    (
40        "calendar.json",
41        "calendar",
42        "v3",
43        "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
44    ),
45    (
46        "drive.json",
47        "drive",
48        "v3",
49        "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
50    ),
51    (
52        "people.json",
53        "people",
54        "v1",
55        "https://people.googleapis.com/$discovery/rest?version=v1",
56    ),
57    (
58        "chat.json",
59        "chat",
60        "v1",
61        "https://chat.googleapis.com/$discovery/rest?version=v1",
62    ),
63    (
64        "forms.json",
65        "forms",
66        "v1",
67        "https://forms.googleapis.com/$discovery/rest?version=v1",
68    ),
69    (
70        "tasks.json",
71        "tasks",
72        "v1",
73        "https://tasks.googleapis.com/$discovery/rest?version=v1",
74    ),
75    (
76        "docs.json",
77        "docs",
78        "v1",
79        "https://docs.googleapis.com/$discovery/rest?version=v1",
80    ),
81    (
82        "sheets.json",
83        "sheets",
84        "v4",
85        "https://sheets.googleapis.com/$discovery/rest?version=v4",
86    ),
87    (
88        "slides.json",
89        "slides",
90        "v1",
91        "https://slides.googleapis.com/$discovery/rest?version=v1",
92    ),
93    (
94        "script.json",
95        "script",
96        "v1",
97        "https://script.googleapis.com/$discovery/rest?version=v1",
98    ),
99    (
100        "analyticsadmin.json",
101        "analyticsadmin",
102        "v1beta",
103        "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta",
104    ),
105    (
106        "analyticsdata.json",
107        "analyticsdata",
108        "v1beta",
109        "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta",
110    ),
111    (
112        "searchconsole.json",
113        "searchconsole",
114        "v1",
115        "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
116    ),
117];
118
119#[derive(Debug, Clone, serde::Serialize, Deserialize)]
120pub struct Parameter {
121    pub name: String,
122    #[serde(rename = "type")]
123    pub kind: String,
124    pub required: bool,
125    #[serde(default)]
126    pub repeated: bool,
127    #[serde(default)]
128    pub location: Option<String>,
129    #[serde(default)]
130    pub description: String,
131    #[serde(default, rename = "enum")]
132    pub enum_values: Option<Vec<String>>,
133    /// Per-enum-value prose, aligned with `enum_values` by index.
134    #[serde(default, rename = "enumDescriptions")]
135    pub enum_descriptions: Option<Vec<String>>,
136    /// Discovery format hint (`int32`, `uint32`, `google-fieldmask`,
137    /// `google-datetime`), surfaced as a JSON-schema `format`.
138    #[serde(default)]
139    pub format: Option<String>,
140    /// Documented default, as Discovery spells it (a string).
141    #[serde(default, rename = "default")]
142    pub default_value: Option<String>,
143    #[serde(default, deserialize_with = "lenient_bound")]
144    pub minimum: Option<f64>,
145    #[serde(default, deserialize_with = "lenient_bound")]
146    pub maximum: Option<f64>,
147    /// Regexp the value must match.
148    #[serde(default)]
149    pub pattern: Option<String>,
150}
151
152/// A pruned response schema: field names, types, array/enum shapes, and
153/// required markers — deliberately without descriptions (that is the bulk
154/// of the raw size, and the shape is what an outputSchema consumer needs).
155#[derive(Debug, Clone, serde::Serialize, Deserialize)]
156pub struct Schema {
157    #[serde(rename = "type")]
158    pub kind: String,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub properties: Option<BTreeMap<String, Schema>>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub items: Option<Box<Schema>>,
163    #[serde(default, rename = "enum", skip_serializing_if = "Option::is_none")]
164    pub enum_values: Option<Vec<String>>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub required: Option<Vec<String>>,
167}
168
169/// Discovery serves `minimum`/`maximum` as JSON numbers in most services
170/// but as strings ("1") in calendar's document; accept either rather than
171/// let one service's formatting take the whole index down.
172fn lenient_bound<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
173where
174    D: serde::Deserializer<'de>,
175{
176    use serde::Deserialize;
177    #[derive(Deserialize)]
178    #[serde(untagged)]
179    enum Bound {
180        Number(f64),
181        Text(String),
182    }
183    Ok(match Option::<Bound>::deserialize(deserializer)? {
184        Some(Bound::Number(n)) => Some(n),
185        Some(Bound::Text(text)) => text.trim().parse::<f64>().ok(),
186        None => None,
187    })
188}
189
190#[derive(Debug, Clone, serde::Serialize, Deserialize)]
191pub struct Method {
192    pub id: String,
193    #[serde(rename = "httpMethod")]
194    pub http_method: String,
195    pub path: String,
196    #[serde(default)]
197    pub flat_path: Option<String>,
198    #[serde(default)]
199    pub description: String,
200    #[serde(default)]
201    pub scopes: Vec<String>,
202    #[serde(default)]
203    pub required: Vec<String>,
204    #[serde(default)]
205    pub parameters: Vec<Parameter>,
206    /// Name of the response schema in the index's `schemas` map, when the
207    /// upstream method documents one (`response.$ref`).
208    #[serde(default, rename = "responseRef")]
209    pub response_ref: Option<String>,
210    /// Name of the request-body schema in the index's `schemas` map, when
211    /// the upstream method documents one (`request.$ref`).
212    #[serde(default, rename = "requestRef")]
213    pub request_ref: Option<String>,
214}
215
216impl Method {
217    /// Narrowest scope this method accepts, or `None` for the public
218    /// methods that need no authorisation at all.
219    ///
220    /// Discovery lists scopes in descending breadth
221    /// (`https://mail.google.com/` first), and the trailing entries are the
222    /// narrow `.../auth/gmail.readonly` style. Preferring the last one keeps
223    /// a `describe` call from requesting full mailbox access.
224    pub fn least_privilege_scope(&self) -> Option<&str> {
225        self.scopes
226            .iter()
227            .rev()
228            .find(|s| s.starts_with("https://www.googleapis.com/auth/"))
229            .map(String::as_str)
230            .or_else(|| self.scopes.first().map(String::as_str))
231    }
232
233    pub fn parameter(&self, name: &str) -> Option<&Parameter> {
234        self.parameters.iter().find(|p| p.name == name)
235    }
236
237    /// Placeholders in the path template, e.g. `{userId}` in
238    /// `gmail/v1/users/{userId}/messages`.
239    ///
240    /// RFC 6570 reserved-expansion markers are stripped: `{+name}` still
241    /// refers to the parameter named `name` (Chat, People and Slides write
242    /// their resource-name paths that way), so the returned names line up
243    /// with Discovery's parameter list.
244    pub fn path_placeholders(&self) -> Vec<&str> {
245        let mut out = Vec::new();
246        let mut rest = self.path.as_str();
247        while let Some(start) = rest.find('{') {
248            let Some(end) = rest[start..].find('}') else {
249                break;
250            };
251            let mut name = &rest[start + 1..start + end];
252            if name.starts_with('+') {
253                name = &name[1..];
254            }
255            out.push(name);
256            rest = &rest[start + end + 1..];
257        }
258        out
259    }
260
261    /// The subset of [`Method::path_placeholders`] declared with RFC 6570
262    /// reserved expansion (`{+name}`): their values are full resource names
263    /// (`spaces/AAA/messages/BBB`) that must keep their slashes in the path.
264    pub fn reserved_path_placeholders(&self) -> Vec<&str> {
265        let mut out = Vec::new();
266        let mut rest = self.path.as_str();
267        while let Some(start) = rest.find('{') {
268            let Some(end) = rest[start..].find('}') else {
269                break;
270            };
271            let name = &rest[start + 1..start + end];
272            if let Some(stripped) = name.strip_prefix('+') {
273                out.push(stripped);
274            }
275            rest = &rest[start + end + 1..];
276        }
277        out
278    }
279}
280
281#[derive(Debug, Clone, serde::Serialize, Deserialize)]
282pub struct Service {
283    pub name: String,
284    pub version: String,
285    #[serde(default)]
286    pub revision: Option<String>,
287    #[serde(default)]
288    pub title: String,
289    #[serde(default, rename = "rootUrl")]
290    pub root_url: Option<String>,
291    #[serde(default, rename = "servicePath")]
292    pub service_path: String,
293    #[serde(default, rename = "basePath")]
294    pub base_path: String,
295    #[serde(default, rename = "batchPath")]
296    pub batch_path: Option<String>,
297    pub methods: Vec<Method>,
298    /// Response schemas by name (Discovery's `schemas`, pruned); methods'
299    /// `response_ref` keys into this map.
300    #[serde(default)]
301    pub schemas: BTreeMap<String, Schema>,
302}
303
304impl Service {
305    pub fn method(&self, id: &str) -> Option<&Method> {
306        self.methods.iter().find(|m| m.id == id)
307    }
308
309    /// Path prefix shared by every method, normalised to have **no** leading
310    /// slash.
311    ///
312    /// Discovery is inconsistent here: Gmail's `basePath` is `gmail/v1/`
313    /// while Drive's is `/drive/v3/`. Since every `rootUrl` ends in `/`,
314    /// concatenating naively yields `https://www.googleapis.com//drive/v3/...`
315    /// — a double slash that Google answers with a 404 rather than a
316    /// redirect. Strip it here, once, instead of at every call site.
317    pub fn base(&self) -> String {
318        let raw = if self.base_path.is_empty() {
319            self.service_path.as_str()
320        } else {
321            self.base_path.as_str()
322        };
323        raw.trim_start_matches('/').to_owned()
324    }
325}
326
327static SERVICES_CACHE: OnceLock<BTreeMap<&'static str, Service>> = OnceLock::new();
328
329/// Where `grr api refresh` stores fetched indexes, and where `services()`
330/// looks for a copy fresher than the embedded baseline.
331///
332/// `~/.grr/discovery/` — next to the config file, so uninstalling grr's
333/// data directory removes it too.
334pub fn cache_dir() -> Option<std::path::PathBuf> {
335    dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
336}
337
338/// Google marks revisions with date-like strings (`20260923`), so a numeric
339/// comparison is exact; an unparseable revision ranks as zero, which makes a
340/// corrupt cache lose to any embedded baseline rather than win.
341fn revision_rank(revision: &str) -> u64 {
342    revision.trim().parse::<u64>().unwrap_or(0)
343}
344
345/// Every distinct scope the index names, sorted and deduped. The consent set
346/// is built from this (`core::auth::scopes`) instead of from a hand-typed
347/// list, so a scope Google adds arrives with the daily discovery PR rather
348/// than waiting for someone to notice a 403. Sorted, because the consent
349/// string must be byte-stable across runs.
350pub fn all_scopes() -> Vec<String> {
351    let mut scopes: Vec<String> = services()
352        .values()
353        .flat_map(|service| &service.methods)
354        .flat_map(|method| &method.scopes)
355        .cloned()
356        .collect();
357    scopes.sort_unstable();
358    scopes.dedup();
359    scopes
360}
361
362/// Every service, keyed by name: the embedded baseline overlaid with any
363/// fresher on-disk cache. Parsed once, on first use.
364///
365/// This is the "best of both worlds": the embedded index keeps `grr api`
366/// instant and offline-capable, while a cache written by `grr api refresh`
367/// (or by a nightly CI run that shipped in a newer release) is preferred
368/// whenever its revision is newer. No network I/O happens here — implicit
369/// use must never block or fail on connectivity — so a user who never
370/// refreshes still gets the compile-time baseline.
371pub fn services() -> &'static BTreeMap<&'static str, Service> {
372    SERVICES_CACHE.get_or_init(|| {
373        let mut map: BTreeMap<&'static str, Service> = BTreeMap::new();
374        for (file, name, version, _url) in SERVICES {
375            // Every SERVICES entry needs its own arm: the wildcard below is
376            // a tripwire, not a fallback — a new service added to SERVICES
377            // without an arm would otherwise silently load the LAST file's
378            // content (four services once presented as Google Slides).
379            let body = match *file {
380                "gmail.json" => include_str!("discovery/gmail.json"),
381                "calendar.json" => include_str!("discovery/calendar.json"),
382                "drive.json" => include_str!("discovery/drive.json"),
383                "people.json" => include_str!("discovery/people.json"),
384                "chat.json" => include_str!("discovery/chat.json"),
385                "forms.json" => include_str!("discovery/forms.json"),
386                "tasks.json" => include_str!("discovery/tasks.json"),
387                "docs.json" => include_str!("discovery/docs.json"),
388                "sheets.json" => include_str!("discovery/sheets.json"),
389                "slides.json" => include_str!("discovery/slides.json"),
390                "script.json" => include_str!("discovery/script.json"),
391                "analyticsadmin.json" => include_str!("discovery/analyticsadmin.json"),
392                "analyticsdata.json" => include_str!("discovery/analyticsdata.json"),
393                "searchconsole.json" => include_str!("discovery/searchconsole.json"),
394                _ => panic!("no embedded discovery index for {file}; add an include_str! arm"),
395            };
396            let embedded = match serde_json::from_str::<Service>(body) {
397                Ok(mut service) => {
398                    // The filename is the source of truth for the name and
399                    // version; trust it over whatever the document claimed so
400                    // a mislabelled upstream doc cannot desync the two.
401                    service.name = (*name).to_owned();
402                    service.version = (*version).to_owned();
403                    service
404                }
405                Err(error) => {
406                    // A malformed embedded index is a build defect, not a
407                    // runtime condition: fail loudly rather than silently
408                    // dropping a service.
409                    panic!("embedded discovery index {file} failed to parse: {error}");
410                }
411            };
412
413            let fresher = cache_dir().and_then(|dir| {
414                let path = dir.join(format!("{name}.json"));
415                let Ok(body) = std::fs::read_to_string(&path) else {
416                    return None;
417                };
418                match serde_json::from_str::<Service>(&body) {
419                    // A corrupt cache must never take the index down: fall
420                    // back to the embedded baseline and say so once.
421                    Ok(cached)
422                        if revision_rank(cached.revision.as_deref().unwrap_or(""))
423                            > revision_rank(embedded.revision.as_deref().unwrap_or("")) =>
424                    {
425                        Some(cached)
426                    }
427                    Ok(_) => None,
428                    Err(error) => {
429                        tracing::warn!(
430                            "discovery cache {} is corrupt ({error}); using the embedded index",
431                            path.display()
432                        );
433                        None
434                    }
435                }
436            });
437
438            map.insert(name, fresher.unwrap_or(embedded));
439        }
440        map
441    })
442}
443
444/// Resolve `service.resource.method` (or `service..method` for the few
445/// top-level methods that have no resource).
446pub fn resolve(id: &str) -> Result<(&'static Service, &'static Method), String> {
447    let id = id.trim().trim_start_matches('.').replace('/', ".");
448    let (service_name, method_id) = id.split_once('.').ok_or_else(|| {
449        format!("`{id}` is not a method id; expected <service>.<resource>.<method>")
450    })?;
451    let service = services().get(service_name).ok_or_else(|| {
452        let known: Vec<&str> = services().keys().copied().collect();
453        format!(
454            "unknown service `{service_name}`; known: {}",
455            known.join(", ")
456        )
457    })?;
458    let method = service.method(method_id).ok_or_else(|| {
459        let mut ids: Vec<&str> = service.methods.iter().map(|m| m.id.as_str()).collect();
460        ids.sort_unstable();
461        // A near-miss is nearly always a typo in the *method* name, not the
462        // resource path, so match on the last segment. Ranking matters:
463        // `users.messages.list` and `users.drafts.list` are both one edit
464        // from `lst`, and only the resource path tells them apart.
465        let wanted = method_id.rsplit('.').next().unwrap_or(method_id);
466        let mut scored: Vec<(usize, std::cmp::Reverse<usize>, &str)> = ids
467            .iter()
468            .copied()
469            .filter_map(|candidate| {
470                let actual = candidate.rsplit('.').next().unwrap_or(candidate);
471                let bucket = if actual == wanted {
472                    0
473                } else if actual.starts_with(wanted) || wanted.starts_with(actual) {
474                    1
475                } else if edit_distance_at_most(actual, wanted, 1) {
476                    // Limit 1, not 2: `lst` is one edit from `list` but two
477                    // from `get`, and admitting distance 2 turns every short
478                    // method name into a candidate.
479                    2
480                } else {
481                    return None;
482                };
483                // Prefer the candidate sharing the most of the resource path
484                // (Reverse: longer shared prefix sorts first).
485                Some((
486                    bucket,
487                    std::cmp::Reverse(shared_prefix(candidate, method_id)),
488                    candidate,
489                ))
490            })
491            .collect();
492        scored.sort();
493        let mut near: Vec<&str> = scored
494            .into_iter()
495            .map(|(_, _, candidate)| candidate)
496            .collect();
497        near.truncate(5);
498        if near.is_empty() {
499            format!(
500                "unknown method `{method_id}` in `{service_name}` ({} methods available)",
501                service.methods.len()
502            )
503        } else {
504            format!(
505                "unknown method `{method_id}`; did you mean: {}",
506                near.join(", ")
507            )
508        }
509    })?;
510    Ok((service, method))
511}
512
513/// Length of the longest common prefix.
514fn shared_prefix(left: &str, right: &str) -> usize {
515    left.chars()
516        .zip(right.chars())
517        .take_while(|(a, b)| a == b)
518        .count()
519}
520
521/// Prune a raw Discovery schema tree to field names, types, array/enum
522/// shapes, and required markers — descriptions dropped (they are the bulk
523/// of the raw size; the shape is what an outputSchema consumer needs).
524///
525/// `$ref` links resolve up to `depth` hops so property names stay visible
526/// without inlining whole subtrees.
527fn prune_schema(
528    schema: &serde_json::Value,
529    schemas: &serde_json::Map<String, serde_json::Value>,
530    depth: u8,
531) -> Schema {
532    use serde_json::Value;
533    let kind_of = |s: &Value| {
534        s.get("type")
535            .and_then(|v| v.as_str())
536            .unwrap_or("object")
537            .to_owned()
538    };
539    if let Some(reference) = schema.get("$ref").and_then(|v| v.as_str()) {
540        if depth >= 2 {
541            return Schema {
542                kind: "object".to_owned(),
543                properties: None,
544                items: None,
545                enum_values: None,
546                required: None,
547            };
548        }
549        return match schemas.get(reference) {
550            Some(target) => prune_schema(target, schemas, depth + 1),
551            None => Schema {
552                kind: "object".to_owned(),
553                properties: None,
554                items: None,
555                enum_values: None,
556                required: None,
557            },
558        };
559    }
560    let properties = schema
561        .get("properties")
562        .and_then(|v| v.as_object())
563        .map(|props| {
564            props
565                .iter()
566                .map(|(k, v)| (k.clone(), prune_schema(v, schemas, depth)))
567                .collect()
568        });
569    let items = schema
570        .get("items")
571        .filter(|v| v.is_object())
572        .map(|v| Box::new(prune_schema(v, schemas, depth)));
573    let enum_values = schema.get("enum").and_then(|v| v.as_array()).map(|a| {
574        a.iter()
575            .filter_map(|s| s.as_str().map(str::to_owned))
576            .collect()
577    });
578    let required = schema.get("required").and_then(|v| v.as_array()).map(|a| {
579        a.iter()
580            .filter_map(|s| s.as_str().map(str::to_owned))
581            .collect()
582    });
583    Schema {
584        kind: kind_of(schema),
585        properties,
586        items,
587        enum_values,
588        required,
589    }
590}
591
592/// Prune the document's `schemas` map, keyed by schema name.
593fn distill_schemas(doc: &serde_json::Value) -> BTreeMap<String, Schema> {
594    let empty = serde_json::Map::new();
595    let schemas = doc
596        .get("schemas")
597        .and_then(|v| v.as_object())
598        .unwrap_or(&empty);
599    schemas
600        .iter()
601        .map(|(name, schema)| (name.clone(), prune_schema(schema, schemas, 0)))
602        .collect()
603}
604
605/// Distil a raw Google Discovery document into the compact index this module
606/// embeds — the per-method half; the `schemas` map is distilled separately
607/// by [`distill_schemas`].
608/// Distil a raw Google Discovery document into the compact index this module
609/// embeds.
610///
611/// This is the Rust twin of `collect()` in `scripts/fetch-discovery.mjs`,
612/// which produces the committed baseline. Both must follow the same rules or
613/// a refreshed cache would disagree with the embedded index in shape. The
614/// rules that matter:
615///   - walk `resources` ENTRIES (not values): the resource name prefixes the
616///     method id;
617///   - descriptions are whitespace-collapsed and truncated to 600 chars (the
618///     raw Gmail doc alone is ~1.5 MB; 300 cut complex methods mid-sentence);
619///   - required parameters are the ones `parameterOrder` names;
620///   - parameters sort by name, methods sort by id — determinism keeps the
621///     nightly refresh diff quiet.
622pub(crate) fn distill(doc: &serde_json::Value) -> Result<Vec<Method>, String> {
623    let mut out = Vec::new();
624    let resources = doc
625        .get("resources")
626        .and_then(|r| r.as_object())
627        .ok_or_else(|| "document has no resources object".to_owned())?;
628
629    fn walk(node: &serde_json::Value, path: &str, out: &mut Vec<Method>) {
630        let Some(object) = node.as_object() else {
631            return;
632        };
633        if let Some(methods) = object.get("methods").and_then(|m| m.as_object()) {
634            for (name, method) in methods {
635                let id = if path.is_empty() {
636                    name.clone()
637                } else {
638                    format!("{path}.{name}")
639                };
640                let http_method = method
641                    .get("httpMethod")
642                    .and_then(|v| v.as_str())
643                    .unwrap_or("GET")
644                    .to_owned();
645                let path_template = method
646                    .get("path")
647                    .and_then(|v| v.as_str())
648                    .unwrap_or("")
649                    .to_owned();
650                let flat_path = method
651                    .get("flatPath")
652                    .and_then(|v| v.as_str())
653                    .map(str::to_owned);
654                let description = method
655                    .get("description")
656                    .and_then(|v| v.as_str())
657                    .unwrap_or("")
658                    .split_whitespace()
659                    .collect::<Vec<_>>()
660                    .join(" ");
661                let description = description.chars().take(600).collect::<String>();
662                let scopes = method
663                    .get("scopes")
664                    .and_then(|v| v.as_array())
665                    .map(|a| {
666                        a.iter()
667                            .filter_map(|s| s.as_str().map(str::to_owned))
668                            .collect::<Vec<String>>()
669                    })
670                    .unwrap_or_default();
671                let required = method
672                    .get("parameterOrder")
673                    .and_then(|v| v.as_array())
674                    .map(|a| {
675                        a.iter()
676                            .filter_map(|s| s.as_str().map(str::to_owned))
677                            .collect::<Vec<String>>()
678                    })
679                    .unwrap_or_default();
680
681                let mut parameters: Vec<Parameter> = method
682                    .get("parameters")
683                    .and_then(|p| p.as_object())
684                    .map(|params| {
685                        params
686                            .iter()
687                            .map(|(pname, p)| Parameter {
688                                name: pname.clone(),
689                                kind: p
690                                    .get("type")
691                                    .and_then(|v| v.as_str())
692                                    .unwrap_or("string")
693                                    .to_owned(),
694                                required: required.iter().any(|r| r == pname),
695                                repeated: p
696                                    .get("repeated")
697                                    .and_then(|v| v.as_bool())
698                                    .unwrap_or(false),
699                                location: p
700                                    .get("location")
701                                    .and_then(|v| v.as_str())
702                                    .map(str::to_owned),
703                                description: {
704                                    let text = p
705                                        .get("description")
706                                        .and_then(|v| v.as_str())
707                                        .unwrap_or("")
708                                        .split_whitespace()
709                                        .collect::<Vec<_>>()
710                                        .join(" ");
711                                    text.chars().take(160).collect::<String>()
712                                },
713                                enum_values: p.get("enum").and_then(|v| v.as_array()).map(|a| {
714                                    a.iter()
715                                        .filter_map(|s| s.as_str().map(str::to_owned))
716                                        .collect()
717                                }),
718                                enum_descriptions: p
719                                    .get("enumDescriptions")
720                                    .and_then(|v| v.as_array())
721                                    .map(|a| {
722                                        a.iter()
723                                            .filter_map(|s| s.as_str().map(str::to_owned))
724                                            .collect()
725                                    }),
726                                format: p.get("format").and_then(|v| v.as_str()).map(str::to_owned),
727                                default_value: p
728                                    .get("default")
729                                    .and_then(|v| v.as_str())
730                                    .map(str::to_owned),
731                                minimum: p.get("minimum").and_then(|v| v.as_f64()),
732                                maximum: p.get("maximum").and_then(|v| v.as_f64()),
733                                pattern: p
734                                    .get("pattern")
735                                    .and_then(|v| v.as_str())
736                                    .map(str::to_owned),
737                            })
738                            .collect()
739                    })
740                    .unwrap_or_default();
741                parameters.sort_by(|a, b| a.name.cmp(&b.name));
742
743                out.push(Method {
744                    id,
745                    http_method,
746                    path: path_template,
747                    flat_path,
748                    description,
749                    scopes,
750                    required,
751                    parameters,
752                    response_ref: method
753                        .get("response")
754                        .and_then(|r| r.get("$ref"))
755                        .and_then(|v| v.as_str())
756                        .map(str::to_owned),
757                    request_ref: method
758                        .get("request")
759                        .and_then(|r| r.get("$ref"))
760                        .and_then(|v| v.as_str())
761                        .map(str::to_owned),
762                });
763            }
764        }
765        if let Some(nested) = object.get("resources").and_then(|r| r.as_object()) {
766            for (rname, resource) in nested {
767                let child = if path.is_empty() {
768                    rname.clone()
769                } else {
770                    format!("{path}.{rname}")
771                };
772                walk(resource, &child, out);
773            }
774        }
775    }
776
777    for (name, resource) in resources {
778        walk(resource, name, &mut out);
779    }
780    out.sort_by(|a, b| a.id.cmp(&b.id));
781    Ok(out)
782}
783
784/// Fetch fresh Discovery documents and write them to the cache directory.
785///
786/// The explicit, network path — `grr api refresh`. Everything else in this
787/// module works offline off the embedded baseline; this is how a user (or a
788/// nightly CI run) pulls the index forward between releases. On any failure
789/// for one service, the embedded baseline simply stays in place for it: a
790/// refresh must never leave the index WORSE than it was.
791pub async fn refresh(filter: Option<&str>) -> Result<Vec<RefreshReport>, String> {
792    use crate::core::http::build_http_client;
793
794    let client = build_http_client().map_err(|e| e.to_string())?;
795    let Some(dir) = cache_dir() else {
796        return Err("could not find your home directory".to_owned());
797    };
798    tokio::fs::create_dir_all(&dir)
799        .await
800        .map_err(|e| e.to_string())?;
801
802    // The services are independent: every fetch runs as its own task instead
803    // of serialising 14 round-trips. Reports are re-sorted into SERVICES
804    // order below, so the CLI output stays deterministic regardless of
805    // completion order.
806    let mut tasks = tokio::task::JoinSet::new();
807    let mut wanted = 0usize;
808    for (file, name, version, url) in SERVICES {
809        if let Some(wanted_name) = filter
810            && !name.eq_ignore_ascii_case(wanted_name)
811        {
812            continue;
813        }
814        wanted += 1;
815        let client = client.clone();
816        let dir = dir.clone();
817        tasks.spawn(async move { fetch_one(&client, file, name, version, url, &dir).await });
818    }
819    if wanted == 0 {
820        return Ok(Vec::new());
821    }
822
823    let mut reports = Vec::with_capacity(wanted);
824    while let Some(joined) = tasks.join_next().await {
825        match joined {
826            Ok(Ok(report)) => reports.push(report),
827            Ok(Err(e)) => {
828                // Preserve the historical all-or-nothing contract: a failed
829                // service aborts the refresh (the serial loop's `?` used to
830                // propagate it) and the remaining tasks are told to stop.
831                tasks.abort_all();
832                return Err(e);
833            }
834            Err(join_error) => {
835                tasks.abort_all();
836                return Err(format!("refresh task failed: {join_error}"));
837            }
838        }
839    }
840    reports.sort_by_key(|report| {
841        SERVICES
842            .iter()
843            .position(|(_, name, _, _)| *name == report.service)
844            .unwrap_or(usize::MAX)
845    });
846    Ok(reports)
847}
848
849/// Fetch, distil and cache one service. Split out of [`refresh`] so all the
850/// services can be in flight at once.
851async fn fetch_one(
852    client: &reqwest::Client,
853    file: &str,
854    name: &str,
855    version: &str,
856    url: &str,
857    dir: &std::path::Path,
858) -> Result<RefreshReport, String> {
859    let body = client
860        .get(url::Url::parse(url).map_err(|e| format!("{name}: bad discovery url: {e}"))?)
861        .send()
862        .await
863        .map_err(|e| format!("{name}: {e}"))?
864        .text()
865        .await
866        .map_err(|e| format!("{name}: {e}"))?;
867
868    let doc: serde_json::Value = serde_json::from_str(&body)
869        .map_err(|e| format!("{name}: discovery response is not JSON: {e}"))?;
870    let revision = doc
871        .get("revision")
872        .and_then(|v| v.as_str())
873        .unwrap_or("")
874        .to_owned();
875
876    // The embedded revision for this service, for the comparison report.
877    let embedded_revision = services()
878        .get(name)
879        .and_then(|s| s.revision.clone())
880        .unwrap_or_default();
881
882    // A stale or failed refresh must not take the index down: only write
883    // the cache when the fetched document distils cleanly AND is not
884    // older than what is already in use.
885    let methods = distill(&doc)?;
886    if revision_rank(&revision) < revision_rank(&embedded_revision) {
887        return Ok(RefreshReport {
888            service: name.to_owned(),
889            from: embedded_revision,
890            to: revision,
891            methods: methods.len(),
892            wrote_cache: false,
893            skipped_older: true,
894        });
895    }
896
897    let index = Service {
898        name: name.to_owned(),
899        version: version.to_owned(),
900        revision: Some(revision.clone()),
901        title: doc
902            .get("title")
903            .and_then(|v| v.as_str())
904            .unwrap_or(name)
905            .to_owned(),
906        root_url: doc
907            .get("rootUrl")
908            .and_then(|v| v.as_str())
909            .map(str::to_owned),
910        service_path: doc
911            .get("servicePath")
912            .and_then(|v| v.as_str())
913            .unwrap_or("")
914            .to_owned(),
915        base_path: doc
916            .get("basePath")
917            .and_then(|v| v.as_str())
918            .unwrap_or("")
919            .to_owned(),
920        batch_path: doc
921            .get("batchPath")
922            .and_then(|v| v.as_str())
923            .map(str::to_owned),
924        schemas: distill_schemas(&doc),
925        methods,
926    };
927    let json = serde_json::to_string(&index).map_err(|e| e.to_string())?;
928    let path = dir.join(file);
929    tokio::fs::write(&path, json)
930        .await
931        .map_err(|e| format!("{}: {e}", path.display()))?;
932
933    Ok(RefreshReport {
934        service: name.to_owned(),
935        from: embedded_revision,
936        to: revision,
937        methods: index.methods.len(),
938        wrote_cache: true,
939        skipped_older: false,
940    })
941}
942
943/// One service's refresh outcome, for the `grr api refresh` report.
944#[derive(Debug, Clone, serde::Serialize)]
945pub struct RefreshReport {
946    pub service: String,
947    pub from: String,
948    pub to: String,
949    pub methods: usize,
950    pub wrote_cache: bool,
951    pub skipped_older: bool,
952}
953
954/// Levenshtein distance, abandoned as soon as it exceeds `limit`.
955///
956/// Method names are short, so the quadratic table is free; the early exit
957/// keeps the whole 308-method scan cheap.
958fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool {
959    let a: Vec<char> = left.chars().collect();
960    let b: Vec<char> = right.chars().collect();
961    if a.len().abs_diff(b.len()) > limit {
962        return false;
963    }
964    let mut previous: Vec<usize> = (0..=b.len()).collect();
965    let mut current = vec![0usize; b.len() + 1];
966    for i in 1..=a.len() {
967        current[0] = i;
968        let mut row_best = current[0];
969        for j in 1..=b.len() {
970            let cost = usize::from(a[i - 1] != b[j - 1]);
971            current[j] = (previous[j] + 1)
972                .min(current[j - 1] + 1)
973                .min(previous[j - 1] + cost);
974            row_best = row_best.min(current[j]);
975        }
976        if row_best > limit {
977            return false;
978        }
979        std::mem::swap(&mut previous, &mut current);
980    }
981    previous[b.len()] <= limit
982}
983
984/// Expand a discovery path template into a URL path, substituting `{...}`
985/// placeholders from the supplied parameters.
986///
987/// Returns an error naming the missing placeholder rather than sending a
988/// request with a literal `{userId}` in it — which Google answers with an
989/// opaque 404.
990///
991/// `{+name}` placeholders (RFC 6570 reserved expansion) keep their value's
992/// slashes and other path characters, per the expansion's meaning: a Chat
993/// space name like `spaces/AAA` must remain a two-segment path, not a
994/// percent-encoded blob.
995pub fn build_path(
996    service: &Service,
997    method: &Method,
998    params: &serde_json::Map<String, serde_json::Value>,
999) -> Result<String, String> {
1000    let reserved = method.reserved_path_placeholders();
1001    let mut path = method.path.clone();
1002    for placeholder in method.path_placeholders() {
1003        let Some(value) = params.get(placeholder) else {
1004            let known: Vec<&str> = method
1005                .parameters
1006                .iter()
1007                .filter(|p| p.required)
1008                .map(|p| p.name.as_str())
1009                .collect();
1010            return Err(format!(
1011                "`{}` needs the `{placeholder}` parameter (path). Required: {}",
1012                method.id,
1013                if known.is_empty() {
1014                    "none".to_owned()
1015                } else {
1016                    known.join(", ")
1017                }
1018            ));
1019        };
1020        // Path values must be percent-encoded: ids legitimately contain
1021        // spaces, slashes and unicode (Drive file names, Gmail labels).
1022        // Reserved-expansion values are the documented exception — their
1023        // `/` separators ARE the path structure.
1024        let text = value_to_string(value);
1025        let encoded = if reserved.contains(&placeholder) {
1026            encode_path_reserved(&text)
1027        } else {
1028            urlencoding::encode(&text).into_owned()
1029        };
1030        path = path
1031            .replace(&format!("{{{placeholder}}}"), &encoded)
1032            .replace(&format!("{{+{placeholder}}}"), &encoded);
1033    }
1034    Ok(format!(
1035        "{}{}",
1036        service.base(),
1037        path.trim_start_matches('/')
1038    ))
1039}
1040
1041/// Percent-encode a path value for RFC 6570 reserved expansion: keep every
1042/// `pchar` (path character) — most importantly `/`, so Google's full
1043/// resource names (`spaces/AAA/messages/BBB`) stay a real path — and
1044/// encode the rest (spaces, `?`, `#`, `%`, non-ASCII, …).
1045fn encode_path_reserved(value: &str) -> String {
1046    let mut out = String::with_capacity(value.len());
1047    for byte in value.bytes() {
1048        match byte {
1049            b'A'..=b'Z'
1050            | b'a'..=b'z'
1051            | b'0'..=b'9'
1052            | b'-'
1053            | b'.'
1054            | b'_'
1055            | b'~'
1056            | b'/'
1057            | b':'
1058            | b'@'
1059            | b'!'
1060            | b'$'
1061            | b'&'
1062            | b'\''
1063            | b'('
1064            | b')'
1065            | b'*'
1066            | b'+'
1067            | b','
1068            | b';'
1069            | b'=' => {
1070                out.push(byte as char);
1071            }
1072            _ => {
1073                // Hand-rolled hex instead of format!(): this runs for every
1074                // non-pchar byte of every encoded path value.
1075                const HEX: &[u8; 16] = b"0123456789ABCDEF";
1076                out.push('%');
1077                out.push(HEX[(byte >> 4) as usize] as char);
1078                out.push(HEX[(byte & 0x0F) as usize] as char);
1079            }
1080        }
1081    }
1082    out
1083}
1084
1085pub fn value_to_string(value: &serde_json::Value) -> String {
1086    match value {
1087        serde_json::Value::String(s) => s.clone(),
1088        serde_json::Value::Null => String::new(),
1089        other => other.to_string(),
1090    }
1091}
1092
1093/// Render a parameter map as a query string, skipping path parameters
1094/// (already substituted) and honouring `repeated: true` for multi-valued
1095/// params such as Gmail's `labelIds`.
1096pub fn build_query(method: &Method, params: &serde_json::Map<String, serde_json::Value>) -> String {
1097    let placeholders = method.path_placeholders();
1098    let mut pairs: Vec<(String, String)> = Vec::new();
1099    for (key, value) in params {
1100        if placeholders.contains(&key.as_str()) {
1101            continue;
1102        }
1103        if let Some(param) = method.parameter(key)
1104            && param.location.as_deref() == Some("path")
1105        {
1106            continue;
1107        }
1108        match value {
1109            serde_json::Value::Array(items) => {
1110                for item in items {
1111                    pairs.push((key.clone(), value_to_string(item)));
1112                }
1113            }
1114            serde_json::Value::Null => {}
1115            other => pairs.push((key.clone(), value_to_string(other))),
1116        }
1117    }
1118    if pairs.is_empty() {
1119        return String::new();
1120    }
1121    pairs.sort();
1122    // One pre-sized buffer instead of N format! strings plus a Vec plus
1123    // join's result — this runs per request URL.
1124    let mut query = String::with_capacity(pairs.len() * 16);
1125    for (k, v) in &pairs {
1126        if !query.is_empty() {
1127            query.push('&');
1128        }
1129        query.push_str(&urlencoding::encode(k));
1130        query.push('=');
1131        query.push_str(&urlencoding::encode(v));
1132    }
1133    format!("?{query}")
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    #[test]
1141    fn every_service_parses_and_has_methods() {
1142        let all = services();
1143        assert_eq!(all.len(), SERVICES.len());
1144        for (name, service) in all {
1145            assert!(!service.methods.is_empty(), "{name} has no methods");
1146        }
1147    }
1148
1149    #[test]
1150    fn the_index_covers_the_whole_workspace_surface() {
1151        // The point of the dynamic path is coverage. If Google's docs change
1152        // shape and we silently parse zero methods, this is what catches it.
1153        let total: usize = services().values().map(|s| s.methods.len()).sum();
1154        assert!(
1155            total >= 300,
1156            "expected 300+ methods across the Workspace APIs, found {total}"
1157        );
1158    }
1159
1160    #[test]
1161    fn every_service_embeds_its_own_index_not_its_neighbors() {
1162        // The include_str! match arms must cover every SERVICES entry. The
1163        // wildcard once mapped the four newer services onto the Slides
1164        // content — inflating the total and only surfacing when `grr ask`
1165        // derived per-service rubrics. Distinct method-id sets catch it.
1166        let all = services();
1167        let distinct: std::collections::BTreeSet<String> = all
1168            .values()
1169            .map(|s| {
1170                s.methods
1171                    .iter()
1172                    .map(|m| m.id.as_str())
1173                    .collect::<Vec<_>>()
1174                    .join(",")
1175            })
1176            .collect();
1177        assert_eq!(
1178            distinct.len(),
1179            all.len(),
1180            "two services share a method-id set — an include_str! arm is missing"
1181        );
1182    }
1183
1184    #[test]
1185    fn resolves_a_nested_method_id() {
1186        let (service, method) =
1187            resolve("gmail.users.messages.list").expect("gmail.users.messages.list");
1188        assert_eq!(service.name, "gmail");
1189        assert_eq!(method.http_method, "GET");
1190        assert_eq!(method.path, "gmail/v1/users/{userId}/messages");
1191    }
1192
1193    #[test]
1194    fn resolves_a_method_without_an_intermediate_resource() {
1195        // `users.getProfile` sits directly on the users resource, so there is
1196        // only one dot before the method name.
1197        let (service, method) = resolve("gmail.users.getProfile").expect("gmail.users.getProfile");
1198        assert_eq!(service.name, "gmail");
1199        assert_eq!(method.http_method, "GET");
1200    }
1201
1202    #[test]
1203    fn accepts_a_leading_dot_and_slashes() {
1204        assert!(resolve(".gmail.users.messages.list").is_ok());
1205        assert!(resolve("gmail/users/messages/list").is_ok());
1206    }
1207
1208    #[test]
1209    fn unknown_service_and_method_are_actionable() {
1210        // A typo close to a real method gets a concrete suggestion.
1211        let err = resolve("gmail.users.messages.lst").unwrap_err();
1212        assert!(err.contains("did you mean"), "unhelpful: {err}");
1213        let err = resolve("nope.thing.method").unwrap_err();
1214        assert!(err.contains("known:"), "should list services: {err}");
1215        let err = resolve("gmailonly").unwrap_err();
1216        assert!(err.contains("expected <service>"), "unhelpful: {err}");
1217    }
1218
1219    #[test]
1220    fn all_scopes_is_the_sorted_union_of_every_method() {
1221        let union = all_scopes();
1222        assert!(
1223            union.windows(2).all(|pair| pair[0] < pair[1]),
1224            "the union must be sorted and deduped"
1225        );
1226        for service in services().values() {
1227            for method in &service.methods {
1228                for scope in &method.scopes {
1229                    assert!(
1230                        union.binary_search(scope).is_ok(),
1231                        "{scope} is not in the union"
1232                    );
1233                }
1234            }
1235        }
1236    }
1237
1238    #[test]
1239    fn the_consent_set_contains_every_indexed_scope_google_will_show() {
1240        // The login requests this set in full, so a scope that is indexed but
1241        // not consented is a method that 403s on first use — the exact
1242        // failure mode the whole design exists to prevent. The one exception
1243        // is Google's app-authentication surface, which a user consent screen
1244        // cannot show: asking for it fails the entire authorization request,
1245        // so it is filtered out of the consent set (core::auth::APP_AUTH_SCOPES)
1246        // and left to the service account those methods actually need.
1247        let consent = crate::core::auth::scopes();
1248        for scope in all_scopes() {
1249            if crate::core::auth::APP_AUTH_SCOPES.contains(&scope.as_str()) {
1250                assert!(
1251                    !consent.contains(&scope),
1252                    "{scope} is app-authentication-only and must not be requested"
1253                );
1254                continue;
1255            }
1256            assert!(
1257                consent.contains(&scope),
1258                "{scope} is indexed but not consented"
1259            );
1260        }
1261        // The curated floor survives alongside the index union.
1262        for scope in [
1263            "https://mail.google.com/",
1264            "https://www.googleapis.com/auth/gmail.readonly",
1265            "https://www.googleapis.com/auth/webmasters.readonly",
1266        ] {
1267            assert!(
1268                consent.iter().any(|consented| consented == scope),
1269                "{scope} was dropped from the consent set"
1270            );
1271        }
1272    }
1273
1274    #[test]
1275    fn least_privilege_scope_prefers_the_narrow_one() {
1276        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1277        // gmail.users.messages.list accepts full-mailbox access first in the
1278        // discovery list; we must not pick that.
1279        let scope = method.least_privilege_scope().expect("a scope");
1280        assert!(scope.ends_with("gmail.readonly"), "picked {scope}");
1281    }
1282
1283    #[test]
1284    fn required_parameters_are_flagged() {
1285        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1286        let user_id = method.parameter("userId").expect("userId param");
1287        assert!(user_id.required);
1288        let max = method.parameter("maxResults").expect("maxResults param");
1289        assert!(!max.required);
1290    }
1291
1292    #[test]
1293    fn builds_a_path_and_reports_missing_placeholders() {
1294        let (service, method) = resolve("gmail.users.messages.get").unwrap();
1295        let mut params = serde_json::Map::new();
1296        params.insert("id".into(), serde_json::json!("m1"));
1297
1298        // A missing path parameter must name itself rather than produce a
1299        // request with a literal brace in the URL.
1300        let err = build_path(service, method, &params).unwrap_err();
1301        assert!(err.contains("userId"), "unhelpful: {err}");
1302
1303        params.insert("userId".into(), serde_json::json!("me"));
1304        assert_eq!(
1305            build_path(service, method, &params).unwrap(),
1306            "gmail/v1/users/me/messages/m1"
1307        );
1308    }
1309
1310    #[test]
1311    fn path_values_are_percent_encoded() {
1312        let (service, method) = resolve("drive.files.get").unwrap();
1313        let mut params = serde_json::Map::new();
1314        params.insert("fileId".into(), serde_json::json!("a b/c+d"));
1315        let path = build_path(service, method, &params).unwrap();
1316        assert!(!path.contains(' '), "space leaked into {path}");
1317        assert!(path.contains("a%20b%2Fc%2Bd"), "not encoded: {path}");
1318    }
1319
1320    #[test]
1321    fn query_excludes_path_params_and_expands_arrays() {
1322        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1323        let mut params = serde_json::Map::new();
1324        params.insert("userId".into(), serde_json::json!("me"));
1325        params.insert("q".into(), serde_json::json!("is:unread"));
1326        params.insert("labelIds".into(), serde_json::json!(["INBOX", "UNREAD"]));
1327
1328        let query = build_query(method, &params);
1329        assert!(!query.contains("userId"), "path param leaked: {query}");
1330        assert!(query.contains("q=is%3Aunread"), "{query}");
1331        assert!(query.contains("labelIds=INBOX"), "{query}");
1332        assert!(query.contains("labelIds=UNREAD"), "{query}");
1333    }
1334
1335    #[test]
1336    fn query_is_empty_when_only_path_params_are_given() {
1337        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1338        let mut params = serde_json::Map::new();
1339        params.insert("userId".into(), serde_json::json!("me"));
1340        assert!(build_query(method, &params).is_empty());
1341    }
1342
1343    #[test]
1344    fn reserved_expansion_placeholders_match_their_parameters() {
1345        // Chat writes paths as `v1/{+name}` while the parameter is `name`:
1346        // the placeholder must resolve by its real name, not `+name`.
1347        let (service, method) = resolve("chat.spaces.get").unwrap();
1348        assert_eq!(method.path_placeholders(), ["name"]);
1349        assert_eq!(method.reserved_path_placeholders(), ["name"]);
1350
1351        let mut params = serde_json::Map::new();
1352        params.insert("name".into(), serde_json::json!("spaces/AAA"));
1353        let path = build_path(service, method, &params).unwrap();
1354        // The slash IS the path structure: reserved expansion keeps it.
1355        assert_eq!(path, "v1/spaces/AAA");
1356    }
1357
1358    #[test]
1359    fn reserved_expansion_only_encodes_non_path_characters() {
1360        let (service, method) = resolve("chat.spaces.get").unwrap();
1361        let mut params = serde_json::Map::new();
1362        params.insert("name".into(), serde_json::json!("spaces/A B?C"));
1363        let path = build_path(service, method, &params).unwrap();
1364        assert_eq!(path, "v1/spaces/A%20B%3FC");
1365    }
1366
1367    #[test]
1368    fn reserved_expansion_placeholders_do_not_leak_into_the_query() {
1369        // `name` was consumed by the path, so it must not also appear as a
1370        // query pair (a stale `{+name}` key used to hide it from the check).
1371        let (_, method) = resolve("chat.spaces.get").unwrap();
1372        let mut params = serde_json::Map::new();
1373        params.insert("name".into(), serde_json::json!("spaces/AAA"));
1374        assert!(build_query(method, &params).is_empty());
1375    }
1376
1377    #[test]
1378    fn plain_placeholders_still_percent_encode_slashes() {
1379        // Non-reserved placeholders keep the strict component encoding:
1380        // a Drive-style id with a slash must never smuggle in extra path
1381        // segments.
1382        let (service, method) = resolve("gmail.users.messages.get").unwrap();
1383        let mut params = serde_json::Map::new();
1384        params.insert("userId".into(), serde_json::json!("me"));
1385        params.insert("id".into(), serde_json::json!("a/b"));
1386        assert!(
1387            build_path(service, method, &params)
1388                .unwrap()
1389                .ends_with("a%2Fb")
1390        );
1391    }
1392
1393    #[test]
1394    fn every_service_base_has_no_leading_slash() {
1395        // rootUrl always ends in '/', so a leading slash on basePath yields
1396        // '//' and a 404 from Google. Gmail and Drive disagree here, which is
1397        // exactly why this is asserted rather than assumed.
1398        for (name, service) in services() {
1399            assert!(
1400                !service.base().starts_with('/'),
1401                "{name} base starts with a slash: {:?}",
1402                service.base()
1403            );
1404            let root = service.root_url.clone().unwrap_or_default();
1405            let sample = format!("{}{}", root, service.base());
1406            // Skip the scheme: "https://" is a legitimate double slash.
1407            let after_scheme = sample
1408                .split_once("://")
1409                .map(|(_, rest)| rest)
1410                .unwrap_or(&sample);
1411            assert!(
1412                !after_scheme.starts_with('/'),
1413                "{name} would build {sample}"
1414            );
1415        }
1416    }
1417
1418    #[test]
1419    fn method_ids_include_their_resource_path() {
1420        // Regression: the distiller once dropped the root resource name,
1421        // producing `messages.list` instead of `users.messages.list`.
1422        let (service, method) = resolve("gmail.users.messages.list").unwrap();
1423        assert_eq!(method.id, "users.messages.list");
1424        assert!(service.methods.iter().any(|m| m.id == "users.threads.get"));
1425    }
1426
1427    #[test]
1428    fn edit_distance_gate_behaves() {
1429        assert!(edit_distance_at_most("list", "lst", 2));
1430        assert!(edit_distance_at_most("files", "file", 2));
1431        assert!(!edit_distance_at_most("list", "trash", 2));
1432        // Length guard must reject before allocating.
1433        assert!(!edit_distance_at_most("a", "abcdefghij", 2));
1434    }
1435
1436    #[test]
1437    fn a_one_character_typo_ranks_the_real_method_first() {
1438        let err = resolve("gmail.users.messages.lst").unwrap_err();
1439        // Alphabetical ordering used to push the answer past the 5-item cut.
1440        let first = err
1441            .split("did you mean:")
1442            .nth(1)
1443            .expect("suggestion list")
1444            .trim()
1445            .lines()
1446            .next()
1447            .unwrap_or("")
1448            .trim()
1449            .trim_end_matches('.');
1450        assert!(
1451            first.contains("users.messages.list"),
1452            "best suggestion was `{first}` in: {err}"
1453        );
1454    }
1455
1456    #[test]
1457    fn most_methods_need_only_a_handful_of_scopes() {
1458        // What `least_privilege_scope` reports in `grr api describe` and in a
1459        // 403 message is a real, narrow answer: one method's scopes, a median
1460        // of three, because Discovery orders them broadest-first.
1461        let counts: Vec<usize> = services()
1462            .values()
1463            .flat_map(|s| s.methods.iter())
1464            .map(|m| m.scopes.len())
1465            .collect();
1466        let total = counts.len();
1467        let within_nine = counts.iter().filter(|c| **c <= 9).count();
1468        assert!(
1469            within_nine * 100 / total >= 90,
1470            "only {within_nine}/{total} methods need <=9 scopes"
1471        );
1472        // A handful of endpoints genuinely accept many (chat.spaceEvents.list
1473        // takes 16); the point is the tail, not the ceiling.
1474        let worst = counts.iter().max().copied().unwrap_or(0);
1475        assert!(worst <= 20, "a method accepts {worst} scopes");
1476    }
1477}