1use serde::Deserialize;
27use std::collections::BTreeMap;
28use std::sync::OnceLock;
29
30const 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 #[serde(default, rename = "enumDescriptions")]
135 pub enum_descriptions: Option<Vec<String>>,
136 #[serde(default)]
139 pub format: Option<String>,
140 #[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 #[serde(default)]
149 pub pattern: Option<String>,
150}
151
152#[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
169fn 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 #[serde(default, rename = "responseRef")]
209 pub response_ref: Option<String>,
210}
211
212impl Method {
213 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 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 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 #[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 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
325pub fn cache_dir() -> Option<std::path::PathBuf> {
331 dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
332}
333
334fn revision_rank(revision: &str) -> u64 {
338 revision.trim().parse::<u64>().unwrap_or(0)
339}
340
341pub 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
358pub 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 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 service.name = (*name).to_owned();
398 service.version = (*version).to_owned();
399 service
400 }
401 Err(error) => {
402 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 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
440pub 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 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 2
476 } else {
477 return None;
478 };
479 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
509fn 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
517fn 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
588fn 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
601pub(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
775pub 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 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 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
840async 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 let embedded_revision = services()
869 .get(name)
870 .and_then(|s| s.revision.clone())
871 .unwrap_or_default();
872
873 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#[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
945fn 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
975pub 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 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
1032fn 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 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
1084pub 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 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 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 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 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 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 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 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 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 let err = build_path(service, method, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms);
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, ¶ms).is_empty());
1332 }
1333
1334 #[test]
1335 fn reserved_expansion_placeholders_match_their_parameters() {
1336 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, ¶ms).unwrap();
1345 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, ¶ms).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 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, ¶ms).is_empty());
1366 }
1367
1368 #[test]
1369 fn plain_placeholders_still_percent_encode_slashes() {
1370 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, ¶ms)
1379 .unwrap()
1380 .ends_with("a%2Fb")
1381 );
1382 }
1383
1384 #[test]
1385 fn every_service_base_has_no_leading_slash() {
1386 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 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 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 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 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 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 let worst = counts.iter().max().copied().unwrap_or(0);
1466 assert!(worst <= 20, "a method accepts {worst} scopes");
1467 }
1468}