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