Skip to main content

grabcli_core/
config.rs

1//! `config.toml`: user settings. Every key is optional.
2//!
3//! ```toml
4//! # Placing real orders stays impossible until the user sets this to true
5//! # themselves. grabcli never writes this key.
6//! allow_ordering = false
7//!
8//! # Default payment method for quotes and orders (Grab's paymentTypeID).
9//! payment_type_id = "123456789"
10//!
11//! # Chrome DevTools Protocol endpoint of a logged-in browser, used by
12//! # `grab auth import` and as hydra/cookie refresh fallback.
13//! cdp = "http://127.0.0.1:9222"
14//!
15//! # Refresh hydra / cookies automatically when they expire (default true).
16//! auto_refresh = true
17//!
18//! # How to mint the hydra device JWT: "auto" (native if a key is present,
19//! # else CDP), "native" or "cdp".
20//! hydra_mode = "auto"
21//!
22//! # Phone number `grab auth login` uses when --phone is not given
23//! # (international form). Only ever printed masked.
24//! phone = "+63XXXXXXXXXX"
25//!
26//! # Local cache for read-only browse data (see cache/mod.rs). TTLs in seconds.
27//! [cache]
28//! enabled = true
29//! image_max_mb = 500
30//! index_max_age_days = 30
31//! [cache.ttl]
32//! list = 600
33//! menu = 1800
34//! details = 21600
35//! categories = 86400
36//! ```
37
38use std::path::{Path, PathBuf};
39
40use serde::Deserialize;
41
42use crate::cache::CacheConfig;
43use crate::error::{Error, Result};
44
45pub const DEFAULT_CDP: &str = "http://127.0.0.1:9222";
46
47#[derive(Debug, Clone, Default, Deserialize)]
48#[serde(default, deny_unknown_fields)]
49pub struct Config {
50    pub allow_ordering: bool,
51    pub payment_type_id: Option<String>,
52    pub cdp: Option<String>,
53    pub auto_refresh: Option<bool>,
54    pub hydra_mode: Option<HydraMode>,
55    /// Path to the hydra signing key (PEM). Default `<config dir>/hydra-key.pem`.
56    pub hydra_key: Option<PathBuf>,
57    /// Page the CDP hydra minter opens. Must be a food.grab.com page that
58    /// makes a hydra-protected API call.
59    pub hydra_page: Option<String>,
60    /// Override the User-Agent sent to Grab (default: the browser's, saved at
61    /// import time).
62    pub user_agent: Option<String>,
63    /// Default phone for `grab auth login` (international form, `+63…`).
64    /// Validated on load; only ever printed masked.
65    pub phone: Option<String>,
66    /// `[cache]`: response cache switch and per-kind TTLs.
67    pub cache: CacheConfig,
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum HydraMode {
73    #[default]
74    Auto,
75    Native,
76    Cdp,
77}
78
79impl std::str::FromStr for HydraMode {
80    type Err = String;
81    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
82        match s.to_ascii_lowercase().as_str() {
83            "auto" => Ok(Self::Auto),
84            "native" => Ok(Self::Native),
85            "cdp" => Ok(Self::Cdp),
86            other => Err(format!("unknown hydra mode `{other}` (auto|native|cdp)")),
87        }
88    }
89}
90
91impl Config {
92    pub fn load(path: &Path) -> Result<Self> {
93        match std::fs::read_to_string(path) {
94            Ok(s) => Self::parse(&s)
95                .map_err(|e| Error::new(e.kind, format!("{}: {}", path.display(), e.message))),
96            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
97            Err(e) => Err(e.into()),
98        }
99    }
100
101    /// Parse and validate `config.toml` text.
102    pub fn parse(s: &str) -> Result<Self> {
103        let c: Self = toml::from_str(s).map_err(|e| Error::invalid(e.to_string()))?;
104        if let Some(p) = &c.phone {
105            // The error never echoes the number.
106            crate::login::split_phone(p)
107                .map_err(|e| Error::invalid(format!("`phone`: {}", e.message)))?;
108        }
109        Ok(c)
110    }
111
112    pub fn auto_refresh(&self) -> bool {
113        self.auto_refresh.unwrap_or(true)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn defaults_keep_ordering_off() -> Result<()> {
123        let dir = tempfile::tempdir()?;
124        let c = Config::load(&dir.path().join("missing.toml"))?;
125        assert!(!c.allow_ordering);
126        assert!(c.auto_refresh());
127        Ok(())
128    }
129
130    #[test]
131    fn parses_keys() -> Result<()> {
132        let c: Config = toml::from_str(
133            "allow_ordering = true\npayment_type_id = \"42\"\nhydra_mode = \"cdp\"\n",
134        )
135        .map_err(|e| Error::invalid(e.to_string()))?;
136        assert!(c.allow_ordering);
137        assert_eq!(c.payment_type_id.as_deref(), Some("42"));
138        assert_eq!(c.hydra_mode, Some(HydraMode::Cdp));
139        Ok(())
140    }
141
142    #[test]
143    fn parses_cache_section() -> Result<()> {
144        use crate::cache::CacheKind;
145        let c: Config = toml::from_str("[cache]\nenabled = false\n[cache.ttl]\nmenu = 60\n")
146            .map_err(|e| Error::invalid(e.to_string()))?;
147        assert!(!c.cache.enabled());
148        assert_eq!(c.cache.ttl(CacheKind::Menu), 60);
149        assert_eq!(c.cache.ttl(CacheKind::List), CacheKind::List.default_ttl());
150        assert!(toml::from_str::<Config>("[cache.ttl]\nquote = 60\n").is_err());
151        Ok(())
152    }
153
154    #[test]
155    fn phone_is_optional() -> Result<()> {
156        assert_eq!(Config::parse("allow_ordering = false\n")?.phone, None);
157        let c = Config::parse("phone = \"+63 900 000 0012\"\n")?;
158        assert_eq!(c.phone.as_deref(), Some("+63 900 000 0012"));
159        Ok(())
160    }
161
162    #[test]
163    fn rejects_malformed_phone() -> Result<()> {
164        for bad in ["09000000012", "+1 555 000 0000", "+63 12", "+63abc", ""] {
165            let e = Config::parse(&format!("phone = \"{bad}\"\n")).err();
166            let e = e.ok_or_else(|| Error::invalid(format!("accepted {bad:?}")))?;
167            assert!(e.message.starts_with("`phone`: "), "{}", e.message);
168        }
169        let dir = tempfile::tempdir()?;
170        let path = dir.path().join("config.toml");
171        std::fs::write(&path, "phone = \"0900 000 0012\"\n")?;
172        let e = Config::load(&path)
173            .err()
174            .ok_or_else(|| Error::invalid("loaded"))?;
175        assert!(
176            e.message
177                .contains("config.toml: `phone`: phone must be in international form")
178        );
179        assert!(!e.message.contains("0012"));
180        Ok(())
181    }
182
183    #[test]
184    fn rejects_unknown_keys() {
185        let r: std::result::Result<Config, _> = toml::from_str("allow_orderin = true");
186        assert!(r.is_err());
187    }
188}