Skip to main content

grabcli_core/
location.rs

1//! The saved delivery address, read from the `location` cookie (URL-encoded
2//! JSON). grabcli always shows the delivery address on a quote so the user can
3//! confirm where the food is going.
4
5use serde::Deserialize;
6
7use crate::error::{Error, ErrorKind, Result};
8use crate::session::Session;
9
10#[derive(Debug, Clone, Deserialize)]
11pub struct Location {
12    #[serde(default)]
13    pub id: String,
14    pub latitude: f64,
15    pub longitude: f64,
16    #[serde(default)]
17    pub address: String,
18    #[serde(default, rename = "countryCode")]
19    pub country_code: String,
20    /// Unit / floor / building line (`addressDetail`), if set in the app.
21    #[serde(default, rename = "addressDetail", deserialize_with = "non_empty")]
22    pub address_detail: Option<String>,
23    /// Saved note to the driver (`noteToDriver`), if set in the app.
24    #[serde(default, rename = "noteToDriver", deserialize_with = "non_empty")]
25    pub note_to_driver: Option<String>,
26}
27
28/// Missing, `null`, and blank strings all become `None`.
29fn non_empty<'de, D: serde::Deserializer<'de>>(
30    d: D,
31) -> std::result::Result<Option<String>, D::Error> {
32    let v: Option<String> = Option::deserialize(d)?;
33    Ok(v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()))
34}
35
36impl Location {
37    pub fn from_session(session: &Session) -> Result<Self> {
38        let raw = session.cookie("location").ok_or_else(|| {
39            Error::new(
40                ErrorKind::AddressMissing,
41                "no saved delivery address (location cookie) in the session; copy it from the \
42                 browser with `grab auth import --address-only` (set an address on food.grab.com \
43                 there first if it has none)",
44            )
45        })?;
46        let decoded =
47            urlencoding::decode(raw).map_err(|e| Error::parse(format!("location cookie: {e}")))?;
48        serde_json::from_str(&decoded)
49            .map_err(|e| Error::parse(format!("location cookie JSON: {e}")))
50    }
51
52    /// The saved driver note with optional extra notes appended (`; `-joined).
53    pub fn delivery_notes(&self, extra: Option<&str>) -> String {
54        let extra = extra.map(str::trim).filter(|s| !s.is_empty());
55        match (self.note_to_driver.as_deref(), extra) {
56            (Some(a), Some(b)) => format!("{a}; {b}"),
57            (Some(a), None) => a.to_string(),
58            (None, Some(b)) => b.to_string(),
59            (None, None) => String::new(),
60        }
61    }
62
63    /// `lat,lng` for query strings.
64    pub fn latlng(&self) -> String {
65        format!("{},{}", self.latitude, self.longitude)
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::session::Cookie;
73
74    #[test]
75    fn parses_url_encoded_location() -> Result<()> {
76        // Synthetic coordinates and address; not a real place.
77        let json = r#"{"id":"POI-EXAMPLE","latitude":1.23,"longitude":4.56,"address":"1 Example St","countryCode":"PH"}"#;
78        let encoded = urlencoding::encode(json).into_owned();
79        let s = Session {
80            cookies: vec![Cookie {
81                name: "location".into(),
82                value: encoded,
83                domain: "food.grab.com".into(),
84                path: "/".into(),
85                expires: -1.0,
86            }],
87            ..Default::default()
88        };
89        let loc = Location::from_session(&s)?;
90        assert_eq!(loc.id, "POI-EXAMPLE");
91        assert_eq!(loc.latlng(), "1.23,4.56");
92        assert_eq!(loc.address, "1 Example St");
93        assert_eq!(loc.address_detail, None);
94        assert_eq!(loc.note_to_driver, None);
95        Ok(())
96    }
97
98    #[test]
99    fn parses_address_detail_and_driver_note() -> Result<()> {
100        let json = r#"{"id":"P","latitude":1.0,"longitude":2.0,"address":"1 Example St","addressDetail":"Unit 1A, Example Tower","noteToDriver":"Leave at lobby"}"#;
101        let loc: Location = serde_json::from_str(json).map_err(|e| Error::parse(e.to_string()))?;
102        assert_eq!(
103            loc.address_detail.as_deref(),
104            Some("Unit 1A, Example Tower")
105        );
106        assert_eq!(loc.note_to_driver.as_deref(), Some("Leave at lobby"));
107        Ok(())
108    }
109
110    #[test]
111    fn null_or_blank_detail_is_none() -> Result<()> {
112        let json = r#"{"latitude":1.0,"longitude":2.0,"addressDetail":null,"noteToDriver":"  "}"#;
113        let loc: Location = serde_json::from_str(json).map_err(|e| Error::parse(e.to_string()))?;
114        assert_eq!(loc.address_detail, None);
115        assert_eq!(loc.note_to_driver, None);
116        Ok(())
117    }
118
119    #[test]
120    fn delivery_notes_append() -> Result<()> {
121        let mut loc: Location = serde_json::from_str(r#"{"latitude":1.0,"longitude":2.0}"#)
122            .map_err(|e| Error::parse(e.to_string()))?;
123        assert_eq!(loc.delivery_notes(None), "");
124        assert_eq!(loc.delivery_notes(Some("Ring bell")), "Ring bell");
125        loc.note_to_driver = Some("Leave at lobby".into());
126        assert_eq!(loc.delivery_notes(None), "Leave at lobby");
127        assert_eq!(loc.delivery_notes(Some(" ")), "Leave at lobby");
128        assert_eq!(
129            loc.delivery_notes(Some("Ring bell")),
130            "Leave at lobby; Ring bell"
131        );
132        Ok(())
133    }
134
135    #[test]
136    fn missing_location_errors() {
137        let e = Location::from_session(&Session::default()).expect_err("should error");
138        assert_eq!(e.kind, ErrorKind::AddressMissing);
139    }
140}