Skip to main content

grabcli_core/
places.rs

1//! Saved places (Home, Work and other saved addresses) from the account's
2//! address book, as food.grab.com's address picker loads them. See
3//! docs/places.md.
4//!
5//! Listing is a read-only GET. [`SavedPlace::to_location`] builds the
6//! `location` cookie value the web app writes when a saved place is picked;
7//! storing it only changes grabcli's local session, nothing on Grab's side.
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11
12use crate::error::{Error, ErrorKind, Result};
13use crate::http::Allowlist;
14use crate::location::Location;
15
16/// `GET` the saved places (web app: `getGetFavoriteAddresses`).
17pub const FAVORITES_URL: &str = "https://food.grab.com/proxy/foodweb/v1/order/geo/favorites";
18
19/// Within this distance of the saved delivery address a place counts as the
20/// same place even when the poiIDs differ.
21const SAME_PLACE_M: f64 = 20.0;
22
23/// The closest place within this distance is reported as "near" the saved
24/// delivery address (e.g. the same building picked as a different POI).
25const NEAR_PLACE_M: f64 = 150.0;
26
27/// How a saved place relates to the saved delivery address.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Match {
31    /// Same poiID or practically the same coordinates.
32    Current,
33    /// The closest place, within [`NEAR_PLACE_M`], but not the same one.
34    Near,
35}
36
37/// Which place (index into `places`) is the saved delivery address: an exact
38/// match if any, else the closest one within [`NEAR_PLACE_M`].
39pub 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
52/// The allowlist for saved-place commands: the one read-only GET.
53pub 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/// One saved place, as returned by the favorites endpoint.
66#[derive(Debug, Clone, Deserialize, Serialize)]
67pub struct SavedPlace {
68    #[serde(rename = "poiID", default)]
69    pub poi_id: String,
70    /// The place name (building / POI name).
71    #[serde(default)]
72    pub name: String,
73    #[serde(default)]
74    pub address: String,
75    pub location: LatLng,
76    /// Raw label: `HOME_TAG_PRESET`, `WORK_TAG_PRESET`, a custom label, or
77    /// empty.
78    #[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
90/// Parse the favorites response (`{"places":[...]}`).
91pub 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    /// The label as the app shows it: `Home` / `Work` for the presets, the
101    /// custom label otherwise, `None` if unlabeled.
102    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    /// `name - address`, the line the web app shows and stores.
121    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    /// Whether this is the place the saved delivery address points at: same
130    /// poiID, or (failing that) practically the same coordinates.
131    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    /// Great-circle distance to the saved delivery address, in metres.
139    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    /// The `location` cookie JSON for this place, shaped like the web app's
152    /// own when a saved place is picked. `unit` and `note` become
153    /// `addressDetail` and `noteToDriver` (empty when `None`).
154    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
172/// Find a place by 1-based list index, poiID, label (`home`, `work`, a custom
173/// label; case-insensitive) or name. Errors if nothing or several match.
174pub 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    // Fake places only: no real addresses or coordinates.
220    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        // ~110 m north of Home: near, not current.
299        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}