1use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11
12use crate::error::{Error, ErrorKind, Result};
13use crate::http::Allowlist;
14use crate::location::Location;
15
16pub const FAVORITES_URL: &str = "https://food.grab.com/proxy/foodweb/v1/order/geo/favorites";
18
19const SAME_PLACE_M: f64 = 20.0;
22
23pub const NEAR_PLACE_M: f64 = 150.0;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Match {
31 Current,
33 Near,
35}
36
37pub fn current_place(places: &[SavedPlace], loc: &Location) -> Option<(usize, Match)> {
40 if let Some(i) = places.iter().position(|p| p.matches(loc)) {
41 return Some((i, Match::Current));
42 }
43 places
44 .iter()
45 .enumerate()
46 .map(|(i, p)| (i, p.distance_m(loc)))
47 .filter(|(_, d)| *d <= NEAR_PLACE_M)
48 .min_by(|a, b| a.1.total_cmp(&b.1))
49 .map(|(i, _)| (i, Match::Near))
50}
51
52pub fn allowlist() -> Allowlist {
54 let mut a = Allowlist::new();
55 a.allow_exact(FAVORITES_URL);
56 a
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
60pub struct LatLng {
61 pub latitude: f64,
62 pub longitude: f64,
63}
64
65#[derive(Debug, Clone, Deserialize, Serialize)]
67pub struct SavedPlace {
68 #[serde(rename = "poiID", default)]
69 pub poi_id: String,
70 #[serde(default)]
72 pub name: String,
73 #[serde(default)]
74 pub address: String,
75 pub location: LatLng,
76 #[serde(default)]
79 pub label: String,
80 #[serde(default)]
81 pub city: String,
82 #[serde(rename = "cityID", default)]
83 pub city_id: String,
84 #[serde(default)]
85 pub country: String,
86 #[serde(rename = "countryCode", default)]
87 pub country_code: String,
88}
89
90pub fn parse_places(v: &Value) -> Result<Vec<SavedPlace>> {
92 match v.get("places") {
93 None | Some(Value::Null) => Ok(Vec::new()),
94 Some(p) => serde_json::from_value(p.clone())
95 .map_err(|e| Error::parse(format!("saved places: {e}"))),
96 }
97}
98
99impl SavedPlace {
100 pub fn display_label(&self) -> Option<String> {
103 let raw = self.label.trim();
104 if raw.is_empty() {
105 return None;
106 }
107 Some(match raw.strip_suffix("_TAG_PRESET") {
108 Some(preset) => {
109 let lower = preset.to_ascii_lowercase();
110 let mut chars = lower.chars();
111 match chars.next() {
112 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
113 None => raw.to_string(),
114 }
115 }
116 None => raw.to_string(),
117 })
118 }
119
120 pub fn formatted_address(&self) -> String {
122 match (self.name.trim(), self.address.trim()) {
123 ("", a) => a.to_string(),
124 (n, "") => n.to_string(),
125 (n, a) => format!("{n} - {a}"),
126 }
127 }
128
129 pub fn matches(&self, loc: &Location) -> bool {
132 if !self.poi_id.is_empty() && self.poi_id == loc.id {
133 return true;
134 }
135 self.distance_m(loc) <= SAME_PLACE_M
136 }
137
138 pub fn distance_m(&self, loc: &Location) -> f64 {
140 const R: f64 = 6_371_000.0;
141 let (la1, la2) = (
142 self.location.latitude.to_radians(),
143 loc.latitude.to_radians(),
144 );
145 let dla = la2 - la1;
146 let dlo = (loc.longitude - self.location.longitude).to_radians();
147 let h = (dla / 2.0).sin().powi(2) + la1.cos() * la2.cos() * (dlo / 2.0).sin().powi(2);
148 2.0 * R * h.sqrt().asin()
149 }
150
151 pub fn to_location(&self, unit: Option<&str>, note: Option<&str>) -> Value {
155 let addr = self.formatted_address();
156 json!({
157 "id": self.poi_id,
158 "latitude": self.location.latitude,
159 "longitude": self.location.longitude,
160 "address": addr,
161 "countryCode": self.country_code,
162 "isAccurate": true,
163 "addressDetail": unit.map(str::trim).unwrap_or_default(),
164 "noteToDriver": note.map(str::trim).unwrap_or_default(),
165 "city": self.city,
166 "cityID": self.city_id.parse::<i64>().unwrap_or_default(),
167 "displayAddress": addr,
168 })
169 }
170}
171
172pub fn find<'a>(places: &'a [SavedPlace], target: &str) -> Result<&'a SavedPlace> {
175 let t = target.trim();
176 if let Ok(n) = t.parse::<usize>()
177 && (1..=places.len()).contains(&n)
178 {
179 return Ok(&places[n - 1]);
180 }
181 if let Some(p) = places
182 .iter()
183 .find(|p| !p.poi_id.is_empty() && p.poi_id == t)
184 {
185 return Ok(p);
186 }
187 let eq = |s: &str| !s.trim().is_empty() && s.trim().eq_ignore_ascii_case(t);
188 for key in [
189 (|p: &SavedPlace| p.display_label().unwrap_or_default()) as fn(&SavedPlace) -> String,
190 |p| p.label.clone(),
191 |p| p.name.clone(),
192 ] {
193 let hits: Vec<&SavedPlace> = places.iter().filter(|p| eq(&key(p))).collect();
194 match hits.as_slice() {
195 [one] => return Ok(one),
196 [] => {}
197 _ => {
198 return Err(Error::new(
199 ErrorKind::InvalidInput,
200 format!(
201 "{} saved places match {t:?}; use the number or poiID from \
202 `grab saved-places list`",
203 hits.len()
204 ),
205 ));
206 }
207 }
208 }
209 Err(Error::new(
210 ErrorKind::NotFound,
211 format!("no saved place matches {t:?}; see `grab saved-places list`"),
212 ))
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn fixture() -> Value {
221 serde_json::from_str(include_str!("../../../docs/fixtures/saved_places.json"))
222 .expect("fixture parses")
223 }
224
225 fn places() -> Vec<SavedPlace> {
226 parse_places(&fixture()).expect("places parse")
227 }
228
229 fn loc(json: &str) -> Location {
230 serde_json::from_str(json).expect("location parses")
231 }
232
233 #[test]
234 fn parses_fixture() {
235 let p = places();
236 assert_eq!(p.len(), 4);
237 assert_eq!(p[0].poi_id, "POI-HOME-EXAMPLE");
238 assert_eq!(p[0].name, "Example Tower");
239 assert_eq!(p[0].address, "1 Example St, Example City");
240 assert_eq!(p[0].location.latitude, 1.23);
241 assert_eq!(p[0].city_id, "4");
242 assert_eq!(p[0].country_code, "PH");
243 }
244
245 #[test]
246 fn empty_or_missing_places() {
247 assert!(parse_places(&json!({})).expect("ok").is_empty());
248 assert!(
249 parse_places(&json!({"places": null}))
250 .expect("ok")
251 .is_empty()
252 );
253 assert!(parse_places(&json!({"places": "x"})).is_err());
254 }
255
256 #[test]
257 fn labels() {
258 let p = places();
259 let l: Vec<Option<String>> = p.iter().map(SavedPlace::display_label).collect();
260 assert_eq!(
261 l,
262 vec![
263 Some("Home".into()),
264 Some("Work".into()),
265 Some("Gym".into()),
266 None
267 ]
268 );
269 }
270
271 #[test]
272 fn formatted_address() {
273 let p = places();
274 assert_eq!(
275 p[0].formatted_address(),
276 "Example Tower - 1 Example St, Example City"
277 );
278 }
279
280 #[test]
281 fn matches_by_poi_or_coords() {
282 let p = places();
283 let by_id =
284 loc(r#"{"id":"POI-HOME-EXAMPLE","latitude":9.0,"longitude":9.0,"address":"x"}"#);
285 assert!(p[0].matches(&by_id));
286 assert!(!p[1].matches(&by_id));
287 let by_coords = loc(r#"{"id":"OTHER","latitude":1.23005,"longitude":4.56005}"#);
288 assert!(p[0].matches(&by_coords));
289 let far = loc(r#"{"id":"OTHER","latitude":1.24,"longitude":4.56}"#);
290 assert!(!p[0].matches(&far));
291 }
292
293 #[test]
294 fn current_place_exact_near_none() {
295 let p = places();
296 let exact = loc(r#"{"id":"POI-WORK-EXAMPLE","latitude":0.0,"longitude":0.0}"#);
297 assert_eq!(current_place(&p, &exact), Some((1, Match::Current)));
298 let near = loc(r#"{"id":"OTHER","latitude":1.231,"longitude":4.56}"#);
300 assert_eq!(current_place(&p, &near), Some((0, Match::Near)));
301 let none = loc(r#"{"id":"OTHER","latitude":1.3,"longitude":4.56}"#);
302 assert_eq!(current_place(&p, &none), None);
303 }
304
305 #[test]
306 fn distance_is_metres() {
307 let p = places();
308 let l = loc(r#"{"latitude":1.24,"longitude":4.56}"#);
309 let d = p[0].distance_m(&l);
310 assert!((d - 1112.0).abs() < 5.0, "{d}");
311 }
312
313 #[test]
314 fn find_by_index_id_label_name() {
315 let p = places();
316 assert_eq!(find(&p, "2").expect("idx").poi_id, "POI-WORK-EXAMPLE");
317 assert_eq!(
318 find(&p, "POI-GYM-EXAMPLE").expect("id").poi_id,
319 "POI-GYM-EXAMPLE"
320 );
321 assert_eq!(find(&p, "home").expect("label").poi_id, "POI-HOME-EXAMPLE");
322 assert_eq!(
323 find(&p, "WORK_TAG_PRESET").expect("raw").poi_id,
324 "POI-WORK-EXAMPLE"
325 );
326 assert_eq!(
327 find(&p, "example plaza").expect("name").poi_id,
328 "POI-OTHER-EXAMPLE"
329 );
330 assert_eq!(
331 find(&p, "nope").expect_err("none").kind,
332 ErrorKind::NotFound
333 );
334 assert_eq!(find(&p, "9").expect_err("range").kind, ErrorKind::NotFound);
335 }
336
337 #[test]
338 fn find_ambiguous() {
339 let mut p = places();
340 p[1].name = p[0].name.clone();
341 assert_eq!(
342 find(&p, "Example Tower").expect_err("ambiguous").kind,
343 ErrorKind::InvalidInput
344 );
345 }
346
347 #[test]
348 fn location_cookie_roundtrips() {
349 let p = places();
350 let v = p[0].to_location(Some(" Unit 1A, Example Tower "), None);
351 assert_eq!(v["id"], "POI-HOME-EXAMPLE");
352 assert_eq!(v["cityID"], 4);
353 assert_eq!(v["isAccurate"], true);
354 assert_eq!(v["noteToDriver"], "");
355 let back: Location = serde_json::from_value(v).expect("location");
356 assert_eq!(back.latlng(), "1.23,4.56");
357 assert_eq!(
358 back.address_detail.as_deref(),
359 Some("Unit 1A, Example Tower")
360 );
361 assert_eq!(back.note_to_driver, None);
362 assert_eq!(back.country_code, "PH");
363 }
364
365 #[test]
366 fn allowlist_is_exact() {
367 let a = allowlist();
368 assert!(a.is_allowed(FAVORITES_URL));
369 assert!(!a.is_allowed("https://food.grab.com/proxy/foodweb/v1/order/geo/favorites/x"));
370 assert!(!a.is_allowed("https://food.grab.com/proxy/foodweb/v2/order/orders"));
371 }
372}