grabcli-core 0.2.0

Shared Grab session, auth, hydra and HTTP plumbing for grabcli
Documentation
//! `config.toml`: user settings. Every key is optional. The library only
//! parses text ([`Config::parse`]) or takes a struct; reading the file (and
//! the hydra key it points to) is the CLI's job.
//!
//! ```toml
//! # Placing real orders stays impossible until the user sets this to true
//! # themselves. grabcli never writes this key.
//! allow_ordering = false
//!
//! # Default payment method for quotes and orders (Grab's paymentTypeID).
//! payment_type_id = "123456789"
//!
//! # Chrome DevTools Protocol endpoint of a logged-in browser, used by
//! # `grab auth import` and as hydra/cookie refresh fallback.
//! cdp = "http://127.0.0.1:9222"
//!
//! # Refresh hydra / cookies automatically when they expire (default true).
//! auto_refresh = true
//!
//! # How to mint the hydra device JWT: "auto" (native if a key is present,
//! # else CDP), "native" or "cdp".
//! hydra_mode = "auto"
//!
//! # Phone number `grab auth login` uses when --phone is not given
//! # (international form). Only ever printed masked.
//! phone = "+63XXXXXXXXXX"
//!
//! # Local cache for read-only browse data (see cache/mod.rs). TTLs in seconds.
//! [cache]
//! enabled = true
//! image_max_mb = 500
//! index_max_age_days = 30
//! [cache.ttl]
//! list = 600
//! menu = 1800
//! details = 21600
//! categories = 86400
//! ```

use std::path::PathBuf;

use serde::Deserialize;

use crate::cache::CacheConfig;
use crate::error::{Error, Result};

pub const DEFAULT_CDP: &str = "http://127.0.0.1:9222";

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub allow_ordering: bool,
    pub payment_type_id: Option<String>,
    pub cdp: Option<String>,
    pub auto_refresh: Option<bool>,
    pub hydra_mode: Option<HydraMode>,
    /// Path to the hydra signing key (PEM), read by the CLI (default
    /// `<config dir>/hydra-key.pem`) and passed via
    /// [`Context::with_hydra_key`](crate::Context::with_hydra_key).
    pub hydra_key: Option<PathBuf>,
    /// Page the CDP hydra minter opens. Must be a food.grab.com page that
    /// makes a hydra-protected API call.
    pub hydra_page: Option<String>,
    /// Override the User-Agent sent to Grab (default: the browser's, saved at
    /// import time).
    pub user_agent: Option<String>,
    /// Default phone for `grab auth login` (international form, `+63…`).
    /// Validated on load; only ever printed masked.
    pub phone: Option<String>,
    /// `[cache]`: response cache switch and per-kind TTLs.
    pub cache: CacheConfig,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HydraMode {
    #[default]
    Auto,
    Native,
    Cdp,
}

impl std::str::FromStr for HydraMode {
    type Err = String;
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s.to_ascii_lowercase().as_str() {
            "auto" => Ok(Self::Auto),
            "native" => Ok(Self::Native),
            "cdp" => Ok(Self::Cdp),
            other => Err(format!("unknown hydra mode `{other}` (auto|native|cdp)")),
        }
    }
}

impl Config {
    /// Parse and validate `config.toml` text.
    pub fn parse(s: &str) -> Result<Self> {
        let c: Self = toml::from_str(s).map_err(|e| Error::invalid(e.to_string()))?;
        if let Some(p) = &c.phone {
            // The error never echoes the number.
            crate::login::split_phone(p)
                .map_err(|e| Error::invalid(format!("`phone`: {}", e.message)))?;
        }
        Ok(c)
    }

    pub fn auto_refresh(&self) -> bool {
        self.auto_refresh.unwrap_or(true)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_keep_ordering_off() -> Result<()> {
        let c = Config::parse("")?;
        assert!(!c.allow_ordering);
        assert!(c.auto_refresh());
        Ok(())
    }

    #[test]
    fn parses_keys() -> Result<()> {
        let c: Config = toml::from_str(
            "allow_ordering = true\npayment_type_id = \"42\"\nhydra_mode = \"cdp\"\n",
        )
        .map_err(|e| Error::invalid(e.to_string()))?;
        assert!(c.allow_ordering);
        assert_eq!(c.payment_type_id.as_deref(), Some("42"));
        assert_eq!(c.hydra_mode, Some(HydraMode::Cdp));
        Ok(())
    }

    #[test]
    fn parses_cache_section() -> Result<()> {
        use crate::cache::CacheKind;
        let c: Config = toml::from_str("[cache]\nenabled = false\n[cache.ttl]\nmenu = 60\n")
            .map_err(|e| Error::invalid(e.to_string()))?;
        assert!(!c.cache.enabled());
        assert_eq!(c.cache.ttl(CacheKind::Menu), 60);
        assert_eq!(c.cache.ttl(CacheKind::List), CacheKind::List.default_ttl());
        assert!(toml::from_str::<Config>("[cache.ttl]\nquote = 60\n").is_err());
        Ok(())
    }

    #[test]
    fn phone_is_optional() -> Result<()> {
        assert_eq!(Config::parse("allow_ordering = false\n")?.phone, None);
        let c = Config::parse("phone = \"+63 900 000 0012\"\n")?;
        assert_eq!(c.phone.as_deref(), Some("+63 900 000 0012"));
        Ok(())
    }

    #[test]
    fn rejects_malformed_phone() -> Result<()> {
        for bad in ["09000000012", "+1 555 000 0000", "+63 12", "+63abc", ""] {
            let e = Config::parse(&format!("phone = \"{bad}\"\n")).err();
            let e = e.ok_or_else(|| Error::invalid(format!("accepted {bad:?}")))?;
            assert!(e.message.starts_with("`phone`: "), "{}", e.message);
        }
        Ok(())
    }

    #[test]
    fn rejects_unknown_keys() {
        let r: std::result::Result<Config, _> = toml::from_str("allow_orderin = true");
        assert!(r.is_err());
    }
}