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 #[serde(default, rename = "requestRef")]
213 pub request_ref: Option<String>,
214}
215
216impl Method {
217 pub fn least_privilege_scope(&self) -> Option<&str> {
225 self.scopes
226 .iter()
227 .rev()
228 .find(|s| s.starts_with("https://www.googleapis.com/auth/"))
229 .map(String::as_str)
230 .or_else(|| self.scopes.first().map(String::as_str))
231 }
232
233 pub fn parameter(&self, name: &str) -> Option<&Parameter> {
234 self.parameters.iter().find(|p| p.name == name)
235 }
236
237 pub fn path_placeholders(&self) -> Vec<&str> {
245 let mut out = Vec::new();
246 let mut rest = self.path.as_str();
247 while let Some(start) = rest.find('{') {
248 let Some(end) = rest[start..].find('}') else {
249 break;
250 };
251 let mut name = &rest[start + 1..start + end];
252 if name.starts_with('+') {
253 name = &name[1..];
254 }
255 out.push(name);
256 rest = &rest[start + end + 1..];
257 }
258 out
259 }
260
261 pub fn reserved_path_placeholders(&self) -> Vec<&str> {
265 let mut out = Vec::new();
266 let mut rest = self.path.as_str();
267 while let Some(start) = rest.find('{') {
268 let Some(end) = rest[start..].find('}') else {
269 break;
270 };
271 let name = &rest[start + 1..start + end];
272 if let Some(stripped) = name.strip_prefix('+') {
273 out.push(stripped);
274 }
275 rest = &rest[start + end + 1..];
276 }
277 out
278 }
279}
280
281#[derive(Debug, Clone, serde::Serialize, Deserialize)]
282pub struct Service {
283 pub name: String,
284 pub version: String,
285 #[serde(default)]
286 pub revision: Option<String>,
287 #[serde(default)]
288 pub title: String,
289 #[serde(default, rename = "rootUrl")]
290 pub root_url: Option<String>,
291 #[serde(default, rename = "servicePath")]
292 pub service_path: String,
293 #[serde(default, rename = "basePath")]
294 pub base_path: String,
295 #[serde(default, rename = "batchPath")]
296 pub batch_path: Option<String>,
297 pub methods: Vec<Method>,
298 #[serde(default)]
301 pub schemas: BTreeMap<String, Schema>,
302}
303
304impl Service {
305 pub fn method(&self, id: &str) -> Option<&Method> {
306 self.methods.iter().find(|m| m.id == id)
307 }
308
309 pub fn base(&self) -> String {
318 let raw = if self.base_path.is_empty() {
319 self.service_path.as_str()
320 } else {
321 self.base_path.as_str()
322 };
323 raw.trim_start_matches('/').to_owned()
324 }
325}
326
327static SERVICES_CACHE: OnceLock<BTreeMap<&'static str, Service>> = OnceLock::new();
328
329pub fn cache_dir() -> Option<std::path::PathBuf> {
335 dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
336}
337
338fn revision_rank(revision: &str) -> u64 {
342 revision.trim().parse::<u64>().unwrap_or(0)
343}
344
345pub fn all_scopes() -> Vec<String> {
351 let mut scopes: Vec<String> = services()
352 .values()
353 .flat_map(|service| &service.methods)
354 .flat_map(|method| &method.scopes)
355 .cloned()
356 .collect();
357 scopes.sort_unstable();
358 scopes.dedup();
359 scopes
360}
361
362pub fn services() -> &'static BTreeMap<&'static str, Service> {
372 SERVICES_CACHE.get_or_init(|| {
373 let mut map: BTreeMap<&'static str, Service> = BTreeMap::new();
374 for (file, name, version, _url) in SERVICES {
375 let body = match *file {
380 "gmail.json" => include_str!("discovery/gmail.json"),
381 "calendar.json" => include_str!("discovery/calendar.json"),
382 "drive.json" => include_str!("discovery/drive.json"),
383 "people.json" => include_str!("discovery/people.json"),
384 "chat.json" => include_str!("discovery/chat.json"),
385 "forms.json" => include_str!("discovery/forms.json"),
386 "tasks.json" => include_str!("discovery/tasks.json"),
387 "docs.json" => include_str!("discovery/docs.json"),
388 "sheets.json" => include_str!("discovery/sheets.json"),
389 "slides.json" => include_str!("discovery/slides.json"),
390 "script.json" => include_str!("discovery/script.json"),
391 "analyticsadmin.json" => include_str!("discovery/analyticsadmin.json"),
392 "analyticsdata.json" => include_str!("discovery/analyticsdata.json"),
393 "searchconsole.json" => include_str!("discovery/searchconsole.json"),
394 _ => panic!("no embedded discovery index for {file}; add an include_str! arm"),
395 };
396 let embedded = match serde_json::from_str::<Service>(body) {
397 Ok(mut service) => {
398 service.name = (*name).to_owned();
402 service.version = (*version).to_owned();
403 service
404 }
405 Err(error) => {
406 panic!("embedded discovery index {file} failed to parse: {error}");
410 }
411 };
412
413 let fresher = cache_dir().and_then(|dir| {
414 let path = dir.join(format!("{name}.json"));
415 let Ok(body) = std::fs::read_to_string(&path) else {
416 return None;
417 };
418 match serde_json::from_str::<Service>(&body) {
419 Ok(cached)
422 if revision_rank(cached.revision.as_deref().unwrap_or(""))
423 > revision_rank(embedded.revision.as_deref().unwrap_or("")) =>
424 {
425 Some(cached)
426 }
427 Ok(_) => None,
428 Err(error) => {
429 tracing::warn!(
430 "discovery cache {} is corrupt ({error}); using the embedded index",
431 path.display()
432 );
433 None
434 }
435 }
436 });
437
438 map.insert(name, fresher.unwrap_or(embedded));
439 }
440 map
441 })
442}
443
444pub fn resolve(id: &str) -> Result<(&'static Service, &'static Method), String> {
447 let id = id.trim().trim_start_matches('.').replace('/', ".");
448 let (service_name, method_id) = id.split_once('.').ok_or_else(|| {
449 format!("`{id}` is not a method id; expected <service>.<resource>.<method>")
450 })?;
451 let service = services().get(service_name).ok_or_else(|| {
452 let known: Vec<&str> = services().keys().copied().collect();
453 format!(
454 "unknown service `{service_name}`; known: {}",
455 known.join(", ")
456 )
457 })?;
458 let method = service.method(method_id).ok_or_else(|| {
459 let mut ids: Vec<&str> = service.methods.iter().map(|m| m.id.as_str()).collect();
460 ids.sort_unstable();
461 let wanted = method_id.rsplit('.').next().unwrap_or(method_id);
466 let mut scored: Vec<(usize, std::cmp::Reverse<usize>, &str)> = ids
467 .iter()
468 .copied()
469 .filter_map(|candidate| {
470 let actual = candidate.rsplit('.').next().unwrap_or(candidate);
471 let bucket = if actual == wanted {
472 0
473 } else if actual.starts_with(wanted) || wanted.starts_with(actual) {
474 1
475 } else if edit_distance_at_most(actual, wanted, 1) {
476 2
480 } else {
481 return None;
482 };
483 Some((
486 bucket,
487 std::cmp::Reverse(shared_prefix(candidate, method_id)),
488 candidate,
489 ))
490 })
491 .collect();
492 scored.sort();
493 let mut near: Vec<&str> = scored
494 .into_iter()
495 .map(|(_, _, candidate)| candidate)
496 .collect();
497 near.truncate(5);
498 if near.is_empty() {
499 format!(
500 "unknown method `{method_id}` in `{service_name}` ({} methods available)",
501 service.methods.len()
502 )
503 } else {
504 format!(
505 "unknown method `{method_id}`; did you mean: {}",
506 near.join(", ")
507 )
508 }
509 })?;
510 Ok((service, method))
511}
512
513fn shared_prefix(left: &str, right: &str) -> usize {
515 left.chars()
516 .zip(right.chars())
517 .take_while(|(a, b)| a == b)
518 .count()
519}
520
521fn prune_schema(
528 schema: &serde_json::Value,
529 schemas: &serde_json::Map<String, serde_json::Value>,
530 depth: u8,
531) -> Schema {
532 use serde_json::Value;
533 let kind_of = |s: &Value| {
534 s.get("type")
535 .and_then(|v| v.as_str())
536 .unwrap_or("object")
537 .to_owned()
538 };
539 if let Some(reference) = schema.get("$ref").and_then(|v| v.as_str()) {
540 if depth >= 2 {
541 return Schema {
542 kind: "object".to_owned(),
543 properties: None,
544 items: None,
545 enum_values: None,
546 required: None,
547 };
548 }
549 return match schemas.get(reference) {
550 Some(target) => prune_schema(target, schemas, depth + 1),
551 None => Schema {
552 kind: "object".to_owned(),
553 properties: None,
554 items: None,
555 enum_values: None,
556 required: None,
557 },
558 };
559 }
560 let properties = schema
561 .get("properties")
562 .and_then(|v| v.as_object())
563 .map(|props| {
564 props
565 .iter()
566 .map(|(k, v)| (k.clone(), prune_schema(v, schemas, depth)))
567 .collect()
568 });
569 let items = schema
570 .get("items")
571 .filter(|v| v.is_object())
572 .map(|v| Box::new(prune_schema(v, schemas, depth)));
573 let enum_values = schema.get("enum").and_then(|v| v.as_array()).map(|a| {
574 a.iter()
575 .filter_map(|s| s.as_str().map(str::to_owned))
576 .collect()
577 });
578 let required = schema.get("required").and_then(|v| v.as_array()).map(|a| {
579 a.iter()
580 .filter_map(|s| s.as_str().map(str::to_owned))
581 .collect()
582 });
583 Schema {
584 kind: kind_of(schema),
585 properties,
586 items,
587 enum_values,
588 required,
589 }
590}
591
592fn distill_schemas(doc: &serde_json::Value) -> BTreeMap<String, Schema> {
594 let empty = serde_json::Map::new();
595 let schemas = doc
596 .get("schemas")
597 .and_then(|v| v.as_object())
598 .unwrap_or(&empty);
599 schemas
600 .iter()
601 .map(|(name, schema)| (name.clone(), prune_schema(schema, schemas, 0)))
602 .collect()
603}
604
605pub(crate) fn distill(doc: &serde_json::Value) -> Result<Vec<Method>, String> {
623 let mut out = Vec::new();
624 let resources = doc
625 .get("resources")
626 .and_then(|r| r.as_object())
627 .ok_or_else(|| "document has no resources object".to_owned())?;
628
629 fn walk(node: &serde_json::Value, path: &str, out: &mut Vec<Method>) {
630 let Some(object) = node.as_object() else {
631 return;
632 };
633 if let Some(methods) = object.get("methods").and_then(|m| m.as_object()) {
634 for (name, method) in methods {
635 let id = if path.is_empty() {
636 name.clone()
637 } else {
638 format!("{path}.{name}")
639 };
640 let http_method = method
641 .get("httpMethod")
642 .and_then(|v| v.as_str())
643 .unwrap_or("GET")
644 .to_owned();
645 let path_template = method
646 .get("path")
647 .and_then(|v| v.as_str())
648 .unwrap_or("")
649 .to_owned();
650 let flat_path = method
651 .get("flatPath")
652 .and_then(|v| v.as_str())
653 .map(str::to_owned);
654 let description = method
655 .get("description")
656 .and_then(|v| v.as_str())
657 .unwrap_or("")
658 .split_whitespace()
659 .collect::<Vec<_>>()
660 .join(" ");
661 let description = description.chars().take(600).collect::<String>();
662 let scopes = method
663 .get("scopes")
664 .and_then(|v| v.as_array())
665 .map(|a| {
666 a.iter()
667 .filter_map(|s| s.as_str().map(str::to_owned))
668 .collect::<Vec<String>>()
669 })
670 .unwrap_or_default();
671 let required = method
672 .get("parameterOrder")
673 .and_then(|v| v.as_array())
674 .map(|a| {
675 a.iter()
676 .filter_map(|s| s.as_str().map(str::to_owned))
677 .collect::<Vec<String>>()
678 })
679 .unwrap_or_default();
680
681 let mut parameters: Vec<Parameter> = method
682 .get("parameters")
683 .and_then(|p| p.as_object())
684 .map(|params| {
685 params
686 .iter()
687 .map(|(pname, p)| Parameter {
688 name: pname.clone(),
689 kind: p
690 .get("type")
691 .and_then(|v| v.as_str())
692 .unwrap_or("string")
693 .to_owned(),
694 required: required.iter().any(|r| r == pname),
695 repeated: p
696 .get("repeated")
697 .and_then(|v| v.as_bool())
698 .unwrap_or(false),
699 location: p
700 .get("location")
701 .and_then(|v| v.as_str())
702 .map(str::to_owned),
703 description: {
704 let text = p
705 .get("description")
706 .and_then(|v| v.as_str())
707 .unwrap_or("")
708 .split_whitespace()
709 .collect::<Vec<_>>()
710 .join(" ");
711 text.chars().take(160).collect::<String>()
712 },
713 enum_values: p.get("enum").and_then(|v| v.as_array()).map(|a| {
714 a.iter()
715 .filter_map(|s| s.as_str().map(str::to_owned))
716 .collect()
717 }),
718 enum_descriptions: p
719 .get("enumDescriptions")
720 .and_then(|v| v.as_array())
721 .map(|a| {
722 a.iter()
723 .filter_map(|s| s.as_str().map(str::to_owned))
724 .collect()
725 }),
726 format: p.get("format").and_then(|v| v.as_str()).map(str::to_owned),
727 default_value: p
728 .get("default")
729 .and_then(|v| v.as_str())
730 .map(str::to_owned),
731 minimum: p.get("minimum").and_then(|v| v.as_f64()),
732 maximum: p.get("maximum").and_then(|v| v.as_f64()),
733 pattern: p
734 .get("pattern")
735 .and_then(|v| v.as_str())
736 .map(str::to_owned),
737 })
738 .collect()
739 })
740 .unwrap_or_default();
741 parameters.sort_by(|a, b| a.name.cmp(&b.name));
742
743 out.push(Method {
744 id,
745 http_method,
746 path: path_template,
747 flat_path,
748 description,
749 scopes,
750 required,
751 parameters,
752 response_ref: method
753 .get("response")
754 .and_then(|r| r.get("$ref"))
755 .and_then(|v| v.as_str())
756 .map(str::to_owned),
757 request_ref: method
758 .get("request")
759 .and_then(|r| r.get("$ref"))
760 .and_then(|v| v.as_str())
761 .map(str::to_owned),
762 });
763 }
764 }
765 if let Some(nested) = object.get("resources").and_then(|r| r.as_object()) {
766 for (rname, resource) in nested {
767 let child = if path.is_empty() {
768 rname.clone()
769 } else {
770 format!("{path}.{rname}")
771 };
772 walk(resource, &child, out);
773 }
774 }
775 }
776
777 for (name, resource) in resources {
778 walk(resource, name, &mut out);
779 }
780 out.sort_by(|a, b| a.id.cmp(&b.id));
781 Ok(out)
782}
783
784pub async fn refresh(filter: Option<&str>) -> Result<Vec<RefreshReport>, String> {
792 use crate::core::http::build_http_client;
793
794 let client = build_http_client().map_err(|e| e.to_string())?;
795 let Some(dir) = cache_dir() else {
796 return Err("could not find your home directory".to_owned());
797 };
798 tokio::fs::create_dir_all(&dir)
799 .await
800 .map_err(|e| e.to_string())?;
801
802 let mut tasks = tokio::task::JoinSet::new();
807 let mut wanted = 0usize;
808 for (file, name, version, url) in SERVICES {
809 if let Some(wanted_name) = filter
810 && !name.eq_ignore_ascii_case(wanted_name)
811 {
812 continue;
813 }
814 wanted += 1;
815 let client = client.clone();
816 let dir = dir.clone();
817 tasks.spawn(async move { fetch_one(&client, file, name, version, url, &dir).await });
818 }
819 if wanted == 0 {
820 return Ok(Vec::new());
821 }
822
823 let mut reports = Vec::with_capacity(wanted);
824 while let Some(joined) = tasks.join_next().await {
825 match joined {
826 Ok(Ok(report)) => reports.push(report),
827 Ok(Err(e)) => {
828 tasks.abort_all();
832 return Err(e);
833 }
834 Err(join_error) => {
835 tasks.abort_all();
836 return Err(format!("refresh task failed: {join_error}"));
837 }
838 }
839 }
840 reports.sort_by_key(|report| {
841 SERVICES
842 .iter()
843 .position(|(_, name, _, _)| *name == report.service)
844 .unwrap_or(usize::MAX)
845 });
846 Ok(reports)
847}
848
849async fn fetch_one(
852 client: &reqwest::Client,
853 file: &str,
854 name: &str,
855 version: &str,
856 url: &str,
857 dir: &std::path::Path,
858) -> Result<RefreshReport, String> {
859 let body = client
860 .get(url::Url::parse(url).map_err(|e| format!("{name}: bad discovery url: {e}"))?)
861 .send()
862 .await
863 .map_err(|e| format!("{name}: {e}"))?
864 .text()
865 .await
866 .map_err(|e| format!("{name}: {e}"))?;
867
868 let doc: serde_json::Value = serde_json::from_str(&body)
869 .map_err(|e| format!("{name}: discovery response is not JSON: {e}"))?;
870 let revision = doc
871 .get("revision")
872 .and_then(|v| v.as_str())
873 .unwrap_or("")
874 .to_owned();
875
876 let embedded_revision = services()
878 .get(name)
879 .and_then(|s| s.revision.clone())
880 .unwrap_or_default();
881
882 let methods = distill(&doc)?;
886 if revision_rank(&revision) < revision_rank(&embedded_revision) {
887 return Ok(RefreshReport {
888 service: name.to_owned(),
889 from: embedded_revision,
890 to: revision,
891 methods: methods.len(),
892 wrote_cache: false,
893 skipped_older: true,
894 });
895 }
896
897 let index = Service {
898 name: name.to_owned(),
899 version: version.to_owned(),
900 revision: Some(revision.clone()),
901 title: doc
902 .get("title")
903 .and_then(|v| v.as_str())
904 .unwrap_or(name)
905 .to_owned(),
906 root_url: doc
907 .get("rootUrl")
908 .and_then(|v| v.as_str())
909 .map(str::to_owned),
910 service_path: doc
911 .get("servicePath")
912 .and_then(|v| v.as_str())
913 .unwrap_or("")
914 .to_owned(),
915 base_path: doc
916 .get("basePath")
917 .and_then(|v| v.as_str())
918 .unwrap_or("")
919 .to_owned(),
920 batch_path: doc
921 .get("batchPath")
922 .and_then(|v| v.as_str())
923 .map(str::to_owned),
924 schemas: distill_schemas(&doc),
925 methods,
926 };
927 let json = serde_json::to_string(&index).map_err(|e| e.to_string())?;
928 let path = dir.join(file);
929 tokio::fs::write(&path, json)
930 .await
931 .map_err(|e| format!("{}: {e}", path.display()))?;
932
933 Ok(RefreshReport {
934 service: name.to_owned(),
935 from: embedded_revision,
936 to: revision,
937 methods: index.methods.len(),
938 wrote_cache: true,
939 skipped_older: false,
940 })
941}
942
943#[derive(Debug, Clone, serde::Serialize)]
945pub struct RefreshReport {
946 pub service: String,
947 pub from: String,
948 pub to: String,
949 pub methods: usize,
950 pub wrote_cache: bool,
951 pub skipped_older: bool,
952}
953
954fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool {
959 let a: Vec<char> = left.chars().collect();
960 let b: Vec<char> = right.chars().collect();
961 if a.len().abs_diff(b.len()) > limit {
962 return false;
963 }
964 let mut previous: Vec<usize> = (0..=b.len()).collect();
965 let mut current = vec![0usize; b.len() + 1];
966 for i in 1..=a.len() {
967 current[0] = i;
968 let mut row_best = current[0];
969 for j in 1..=b.len() {
970 let cost = usize::from(a[i - 1] != b[j - 1]);
971 current[j] = (previous[j] + 1)
972 .min(current[j - 1] + 1)
973 .min(previous[j - 1] + cost);
974 row_best = row_best.min(current[j]);
975 }
976 if row_best > limit {
977 return false;
978 }
979 std::mem::swap(&mut previous, &mut current);
980 }
981 previous[b.len()] <= limit
982}
983
984pub fn build_path(
996 service: &Service,
997 method: &Method,
998 params: &serde_json::Map<String, serde_json::Value>,
999) -> Result<String, String> {
1000 let reserved = method.reserved_path_placeholders();
1001 let mut path = method.path.clone();
1002 for placeholder in method.path_placeholders() {
1003 let Some(value) = params.get(placeholder) else {
1004 let known: Vec<&str> = method
1005 .parameters
1006 .iter()
1007 .filter(|p| p.required)
1008 .map(|p| p.name.as_str())
1009 .collect();
1010 return Err(format!(
1011 "`{}` needs the `{placeholder}` parameter (path). Required: {}",
1012 method.id,
1013 if known.is_empty() {
1014 "none".to_owned()
1015 } else {
1016 known.join(", ")
1017 }
1018 ));
1019 };
1020 let text = value_to_string(value);
1025 let encoded = if reserved.contains(&placeholder) {
1026 encode_path_reserved(&text)
1027 } else {
1028 urlencoding::encode(&text).into_owned()
1029 };
1030 path = path
1031 .replace(&format!("{{{placeholder}}}"), &encoded)
1032 .replace(&format!("{{+{placeholder}}}"), &encoded);
1033 }
1034 Ok(format!(
1035 "{}{}",
1036 service.base(),
1037 path.trim_start_matches('/')
1038 ))
1039}
1040
1041fn encode_path_reserved(value: &str) -> String {
1046 let mut out = String::with_capacity(value.len());
1047 for byte in value.bytes() {
1048 match byte {
1049 b'A'..=b'Z'
1050 | b'a'..=b'z'
1051 | b'0'..=b'9'
1052 | b'-'
1053 | b'.'
1054 | b'_'
1055 | b'~'
1056 | b'/'
1057 | b':'
1058 | b'@'
1059 | b'!'
1060 | b'$'
1061 | b'&'
1062 | b'\''
1063 | b'('
1064 | b')'
1065 | b'*'
1066 | b'+'
1067 | b','
1068 | b';'
1069 | b'=' => {
1070 out.push(byte as char);
1071 }
1072 _ => {
1073 const HEX: &[u8; 16] = b"0123456789ABCDEF";
1076 out.push('%');
1077 out.push(HEX[(byte >> 4) as usize] as char);
1078 out.push(HEX[(byte & 0x0F) as usize] as char);
1079 }
1080 }
1081 }
1082 out
1083}
1084
1085pub fn value_to_string(value: &serde_json::Value) -> String {
1086 match value {
1087 serde_json::Value::String(s) => s.clone(),
1088 serde_json::Value::Null => String::new(),
1089 other => other.to_string(),
1090 }
1091}
1092
1093pub fn build_query(method: &Method, params: &serde_json::Map<String, serde_json::Value>) -> String {
1097 let placeholders = method.path_placeholders();
1098 let mut pairs: Vec<(String, String)> = Vec::new();
1099 for (key, value) in params {
1100 if placeholders.contains(&key.as_str()) {
1101 continue;
1102 }
1103 if let Some(param) = method.parameter(key)
1104 && param.location.as_deref() == Some("path")
1105 {
1106 continue;
1107 }
1108 match value {
1109 serde_json::Value::Array(items) => {
1110 for item in items {
1111 pairs.push((key.clone(), value_to_string(item)));
1112 }
1113 }
1114 serde_json::Value::Null => {}
1115 other => pairs.push((key.clone(), value_to_string(other))),
1116 }
1117 }
1118 if pairs.is_empty() {
1119 return String::new();
1120 }
1121 pairs.sort();
1122 let mut query = String::with_capacity(pairs.len() * 16);
1125 for (k, v) in &pairs {
1126 if !query.is_empty() {
1127 query.push('&');
1128 }
1129 query.push_str(&urlencoding::encode(k));
1130 query.push('=');
1131 query.push_str(&urlencoding::encode(v));
1132 }
1133 format!("?{query}")
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 #[test]
1141 fn every_service_parses_and_has_methods() {
1142 let all = services();
1143 assert_eq!(all.len(), SERVICES.len());
1144 for (name, service) in all {
1145 assert!(!service.methods.is_empty(), "{name} has no methods");
1146 }
1147 }
1148
1149 #[test]
1150 fn the_index_covers_the_whole_workspace_surface() {
1151 let total: usize = services().values().map(|s| s.methods.len()).sum();
1154 assert!(
1155 total >= 300,
1156 "expected 300+ methods across the Workspace APIs, found {total}"
1157 );
1158 }
1159
1160 #[test]
1161 fn every_service_embeds_its_own_index_not_its_neighbors() {
1162 let all = services();
1167 let distinct: std::collections::BTreeSet<String> = all
1168 .values()
1169 .map(|s| {
1170 s.methods
1171 .iter()
1172 .map(|m| m.id.as_str())
1173 .collect::<Vec<_>>()
1174 .join(",")
1175 })
1176 .collect();
1177 assert_eq!(
1178 distinct.len(),
1179 all.len(),
1180 "two services share a method-id set — an include_str! arm is missing"
1181 );
1182 }
1183
1184 #[test]
1185 fn resolves_a_nested_method_id() {
1186 let (service, method) =
1187 resolve("gmail.users.messages.list").expect("gmail.users.messages.list");
1188 assert_eq!(service.name, "gmail");
1189 assert_eq!(method.http_method, "GET");
1190 assert_eq!(method.path, "gmail/v1/users/{userId}/messages");
1191 }
1192
1193 #[test]
1194 fn resolves_a_method_without_an_intermediate_resource() {
1195 let (service, method) = resolve("gmail.users.getProfile").expect("gmail.users.getProfile");
1198 assert_eq!(service.name, "gmail");
1199 assert_eq!(method.http_method, "GET");
1200 }
1201
1202 #[test]
1203 fn accepts_a_leading_dot_and_slashes() {
1204 assert!(resolve(".gmail.users.messages.list").is_ok());
1205 assert!(resolve("gmail/users/messages/list").is_ok());
1206 }
1207
1208 #[test]
1209 fn unknown_service_and_method_are_actionable() {
1210 let err = resolve("gmail.users.messages.lst").unwrap_err();
1212 assert!(err.contains("did you mean"), "unhelpful: {err}");
1213 let err = resolve("nope.thing.method").unwrap_err();
1214 assert!(err.contains("known:"), "should list services: {err}");
1215 let err = resolve("gmailonly").unwrap_err();
1216 assert!(err.contains("expected <service>"), "unhelpful: {err}");
1217 }
1218
1219 #[test]
1220 fn all_scopes_is_the_sorted_union_of_every_method() {
1221 let union = all_scopes();
1222 assert!(
1223 union.windows(2).all(|pair| pair[0] < pair[1]),
1224 "the union must be sorted and deduped"
1225 );
1226 for service in services().values() {
1227 for method in &service.methods {
1228 for scope in &method.scopes {
1229 assert!(
1230 union.binary_search(scope).is_ok(),
1231 "{scope} is not in the union"
1232 );
1233 }
1234 }
1235 }
1236 }
1237
1238 #[test]
1239 fn the_consent_set_contains_every_indexed_scope_google_will_show() {
1240 let consent = crate::core::auth::scopes();
1248 for scope in all_scopes() {
1249 if crate::core::auth::APP_AUTH_SCOPES.contains(&scope.as_str()) {
1250 assert!(
1251 !consent.contains(&scope),
1252 "{scope} is app-authentication-only and must not be requested"
1253 );
1254 continue;
1255 }
1256 assert!(
1257 consent.contains(&scope),
1258 "{scope} is indexed but not consented"
1259 );
1260 }
1261 for scope in [
1263 "https://mail.google.com/",
1264 "https://www.googleapis.com/auth/gmail.readonly",
1265 "https://www.googleapis.com/auth/webmasters.readonly",
1266 ] {
1267 assert!(
1268 consent.iter().any(|consented| consented == scope),
1269 "{scope} was dropped from the consent set"
1270 );
1271 }
1272 }
1273
1274 #[test]
1275 fn least_privilege_scope_prefers_the_narrow_one() {
1276 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1277 let scope = method.least_privilege_scope().expect("a scope");
1280 assert!(scope.ends_with("gmail.readonly"), "picked {scope}");
1281 }
1282
1283 #[test]
1284 fn required_parameters_are_flagged() {
1285 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1286 let user_id = method.parameter("userId").expect("userId param");
1287 assert!(user_id.required);
1288 let max = method.parameter("maxResults").expect("maxResults param");
1289 assert!(!max.required);
1290 }
1291
1292 #[test]
1293 fn builds_a_path_and_reports_missing_placeholders() {
1294 let (service, method) = resolve("gmail.users.messages.get").unwrap();
1295 let mut params = serde_json::Map::new();
1296 params.insert("id".into(), serde_json::json!("m1"));
1297
1298 let err = build_path(service, method, ¶ms).unwrap_err();
1301 assert!(err.contains("userId"), "unhelpful: {err}");
1302
1303 params.insert("userId".into(), serde_json::json!("me"));
1304 assert_eq!(
1305 build_path(service, method, ¶ms).unwrap(),
1306 "gmail/v1/users/me/messages/m1"
1307 );
1308 }
1309
1310 #[test]
1311 fn path_values_are_percent_encoded() {
1312 let (service, method) = resolve("drive.files.get").unwrap();
1313 let mut params = serde_json::Map::new();
1314 params.insert("fileId".into(), serde_json::json!("a b/c+d"));
1315 let path = build_path(service, method, ¶ms).unwrap();
1316 assert!(!path.contains(' '), "space leaked into {path}");
1317 assert!(path.contains("a%20b%2Fc%2Bd"), "not encoded: {path}");
1318 }
1319
1320 #[test]
1321 fn query_excludes_path_params_and_expands_arrays() {
1322 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1323 let mut params = serde_json::Map::new();
1324 params.insert("userId".into(), serde_json::json!("me"));
1325 params.insert("q".into(), serde_json::json!("is:unread"));
1326 params.insert("labelIds".into(), serde_json::json!(["INBOX", "UNREAD"]));
1327
1328 let query = build_query(method, ¶ms);
1329 assert!(!query.contains("userId"), "path param leaked: {query}");
1330 assert!(query.contains("q=is%3Aunread"), "{query}");
1331 assert!(query.contains("labelIds=INBOX"), "{query}");
1332 assert!(query.contains("labelIds=UNREAD"), "{query}");
1333 }
1334
1335 #[test]
1336 fn query_is_empty_when_only_path_params_are_given() {
1337 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1338 let mut params = serde_json::Map::new();
1339 params.insert("userId".into(), serde_json::json!("me"));
1340 assert!(build_query(method, ¶ms).is_empty());
1341 }
1342
1343 #[test]
1344 fn reserved_expansion_placeholders_match_their_parameters() {
1345 let (service, method) = resolve("chat.spaces.get").unwrap();
1348 assert_eq!(method.path_placeholders(), ["name"]);
1349 assert_eq!(method.reserved_path_placeholders(), ["name"]);
1350
1351 let mut params = serde_json::Map::new();
1352 params.insert("name".into(), serde_json::json!("spaces/AAA"));
1353 let path = build_path(service, method, ¶ms).unwrap();
1354 assert_eq!(path, "v1/spaces/AAA");
1356 }
1357
1358 #[test]
1359 fn reserved_expansion_only_encodes_non_path_characters() {
1360 let (service, method) = resolve("chat.spaces.get").unwrap();
1361 let mut params = serde_json::Map::new();
1362 params.insert("name".into(), serde_json::json!("spaces/A B?C"));
1363 let path = build_path(service, method, ¶ms).unwrap();
1364 assert_eq!(path, "v1/spaces/A%20B%3FC");
1365 }
1366
1367 #[test]
1368 fn reserved_expansion_placeholders_do_not_leak_into_the_query() {
1369 let (_, method) = resolve("chat.spaces.get").unwrap();
1372 let mut params = serde_json::Map::new();
1373 params.insert("name".into(), serde_json::json!("spaces/AAA"));
1374 assert!(build_query(method, ¶ms).is_empty());
1375 }
1376
1377 #[test]
1378 fn plain_placeholders_still_percent_encode_slashes() {
1379 let (service, method) = resolve("gmail.users.messages.get").unwrap();
1383 let mut params = serde_json::Map::new();
1384 params.insert("userId".into(), serde_json::json!("me"));
1385 params.insert("id".into(), serde_json::json!("a/b"));
1386 assert!(
1387 build_path(service, method, ¶ms)
1388 .unwrap()
1389 .ends_with("a%2Fb")
1390 );
1391 }
1392
1393 #[test]
1394 fn every_service_base_has_no_leading_slash() {
1395 for (name, service) in services() {
1399 assert!(
1400 !service.base().starts_with('/'),
1401 "{name} base starts with a slash: {:?}",
1402 service.base()
1403 );
1404 let root = service.root_url.clone().unwrap_or_default();
1405 let sample = format!("{}{}", root, service.base());
1406 let after_scheme = sample
1408 .split_once("://")
1409 .map(|(_, rest)| rest)
1410 .unwrap_or(&sample);
1411 assert!(
1412 !after_scheme.starts_with('/'),
1413 "{name} would build {sample}"
1414 );
1415 }
1416 }
1417
1418 #[test]
1419 fn method_ids_include_their_resource_path() {
1420 let (service, method) = resolve("gmail.users.messages.list").unwrap();
1423 assert_eq!(method.id, "users.messages.list");
1424 assert!(service.methods.iter().any(|m| m.id == "users.threads.get"));
1425 }
1426
1427 #[test]
1428 fn edit_distance_gate_behaves() {
1429 assert!(edit_distance_at_most("list", "lst", 2));
1430 assert!(edit_distance_at_most("files", "file", 2));
1431 assert!(!edit_distance_at_most("list", "trash", 2));
1432 assert!(!edit_distance_at_most("a", "abcdefghij", 2));
1434 }
1435
1436 #[test]
1437 fn a_one_character_typo_ranks_the_real_method_first() {
1438 let err = resolve("gmail.users.messages.lst").unwrap_err();
1439 let first = err
1441 .split("did you mean:")
1442 .nth(1)
1443 .expect("suggestion list")
1444 .trim()
1445 .lines()
1446 .next()
1447 .unwrap_or("")
1448 .trim()
1449 .trim_end_matches('.');
1450 assert!(
1451 first.contains("users.messages.list"),
1452 "best suggestion was `{first}` in: {err}"
1453 );
1454 }
1455
1456 #[test]
1457 fn most_methods_need_only_a_handful_of_scopes() {
1458 let counts: Vec<usize> = services()
1462 .values()
1463 .flat_map(|s| s.methods.iter())
1464 .map(|m| m.scopes.len())
1465 .collect();
1466 let total = counts.len();
1467 let within_nine = counts.iter().filter(|c| **c <= 9).count();
1468 assert!(
1469 within_nine * 100 / total >= 90,
1470 "only {within_nine}/{total} methods need <=9 scopes"
1471 );
1472 let worst = counts.iter().max().copied().unwrap_or(0);
1475 assert!(worst <= 20, "a method accepts {worst} scopes");
1476 }
1477}