Skip to main content

grr_cli/core/
config_loader.rs

1//! Configuration loading with TOML file and environment variable support
2
3use crate::core::config::{GrrConfig, embedded_oauth_client};
4use crate::core::error::{GrrError, Result};
5use figment::{
6    Figment, Provider,
7    providers::Format,
8    providers::{Env, Toml},
9};
10use std::path::PathBuf;
11use tracing::info;
12
13/// Configuration loader that merges TOML file with environment variables
14pub struct ConfigLoader;
15
16impl ConfigLoader {
17    /// Load configuration from TOML file with environment variable overrides
18    ///
19    /// Priority order (highest to lowest):
20    /// 1. Environment variables (`GRR_OAUTH__CLIENT_ID` / `GRR_OAUTH__CLIENT_SECRET`)
21    /// 2. TOML config file (`~/.grr/config.toml`)
22    /// 3. The OAuth client compiled in at build time (release binaries)
23    pub async fn load() -> Result<GrrConfig> {
24        let config_path = Self::config_path()?;
25
26        info!("Loading config from: {:?}", config_path);
27
28        let mut config = tokio::task::spawn_blocking(move || {
29            Figment::new()
30                .merge(Toml::file(&config_path))
31                .merge(Self::env_provider())
32                .extract::<GrrConfig>()
33                .map_err(|e| e.to_string())
34        })
35        .await
36        .map_err(|e| GrrError::Config(e.to_string()))?
37        .map_err(|e| GrrError::Config(e.to_string()))?;
38
39        config = Self::apply_embedded_default(config);
40
41        info!("Loaded config: client_id={}", config.oauth.client_id);
42        match config.oauth.client_secret.as_deref() {
43            Some(s) if !s.is_empty() => info!("client_secret: configured"),
44            _ => info!("client_secret: absent (PKCE-only)"),
45        }
46        Ok(config)
47    }
48
49    /// Fill in the build-time OAuth client when nothing else supplied one.
50    ///
51    /// This is the zero-config path: release binaries are compiled with a
52    /// client, so a fresh install can `grr auth login` with no setup at all.
53    fn apply_embedded_default(config: GrrConfig) -> GrrConfig {
54        Self::apply_client(config, embedded_oauth_client())
55    }
56
57    fn apply_client(mut config: GrrConfig, embedded: Option<(&str, &str)>) -> GrrConfig {
58        if !config.oauth.client_id.trim().is_empty() {
59            return config;
60        }
61
62        let Some((client_id, client_secret)) = embedded else {
63            return config;
64        };
65
66        info!("Using the OAuth client built into this binary");
67        config.oauth.client_id = client_id.to_owned();
68        config.oauth.client_secret = Some(client_secret.to_owned());
69        config
70    }
71
72    /// Create an Env provider that maps GRR_* env vars to kebab-case keys
73    /// matching the serde rename_all = "kebab-case" setting
74    fn env_provider() -> impl Provider {
75        Env::raw().filter_map(|key| {
76            let key = key.as_str();
77            // TYPESAFE_API_KEY -> systemone.api-key: the standard env route
78            // for the System One key, mirroring the OAuth secret's
79            // discipline (a key in a config file can leak with the file).
80            if key.eq_ignore_ascii_case("TYPESAFE_API_KEY") {
81                return Some("systemone.api-key".into());
82            }
83            if let Some(stripped) = key.strip_prefix("GRR_") {
84                // GRR_OAUTH__CLIENT_ID -> oauth.client-id
85                // First replace __ with . for nesting, then _ with - for field names
86                let key = stripped.replace("__", ".");
87                let key = key.replace('_', "-");
88                let key = key.to_ascii_lowercase();
89                Some(key.into())
90            } else {
91                None
92            }
93        })
94    }
95
96    /// Determine the config file path
97    ///
98    /// Checks in order:
99    /// 1. GRR_CONFIG_PATH environment variable
100    /// 2. ~/.grr/config.toml (the only location — no legacy fallbacks)
101    ///
102    /// Public because `grr auth setup` writes to the same place, and a
103    /// second copy of this logic would eventually drift.
104    pub fn config_path() -> Result<PathBuf> {
105        if let Ok(path) = std::env::var("GRR_CONFIG_PATH") {
106            return Ok(PathBuf::from(path));
107        }
108
109        let home = dirs::home_dir()
110            .ok_or_else(|| GrrError::Config("Could not find home directory".into()))?;
111
112        Ok(home.join(".grr").join("config.toml"))
113    }
114}
115
116#[cfg(test)]
117mod resolution_order_tests {
118    use super::*;
119    use crate::core::config::OAuthConfig;
120
121    fn config_with(id: &str, secret: Option<&str>) -> GrrConfig {
122        GrrConfig {
123            oauth: OAuthConfig {
124                client_id: id.to_owned(),
125                client_secret: secret.map(str::to_owned),
126            },
127            ..GrrConfig::default()
128        }
129    }
130
131    const EMBEDDED: (&str, &str) = ("embedded-id.apps.googleusercontent.com", "embedded-secret");
132
133    #[test]
134    fn embedded_client_fills_an_empty_config() {
135        let resolved = ConfigLoader::apply_client(config_with("", None), Some(EMBEDDED));
136        assert_eq!(resolved.oauth.client_id, EMBEDDED.0);
137        assert_eq!(resolved.oauth.client_secret.as_deref(), Some(EMBEDDED.1));
138    }
139
140    #[test]
141    fn a_configured_client_id_is_never_overwritten() {
142        // A user-supplied client (file or GRR_OAUTH__CLIENT_ID) must win,
143        // even if only the secret was left empty.
144        let resolved = ConfigLoader::apply_client(
145            config_with("mine.apps.googleusercontent.com", None),
146            Some(EMBEDDED),
147        );
148        assert_eq!(resolved.oauth.client_id, "mine.apps.googleusercontent.com");
149        assert!(resolved.oauth.client_secret.is_none());
150    }
151
152    #[test]
153    fn a_whitespace_only_client_id_still_falls_back_to_embedded() {
154        // figment can hand back "" for a missing key; a stray space should
155        // not defeat the zero-config path.
156        let resolved = ConfigLoader::apply_client(config_with("   ", None), Some(EMBEDDED));
157        assert_eq!(resolved.oauth.client_id, EMBEDDED.0);
158    }
159
160    #[test]
161    fn without_an_embedded_client_the_config_is_left_alone() {
162        // Source builds with no .env: the guard in cli.rs reports the
163        // actionable "no OAuth client" help instead.
164        let resolved = ConfigLoader::apply_client(config_with("", None), None);
165        assert!(resolved.oauth.client_id.is_empty());
166        assert!(resolved.oauth.client_secret.is_none());
167    }
168
169    #[test]
170    fn embedded_defaults_are_all_or_nothing() {
171        // A half-set pair must not produce a client id with no secret:
172        // Google rejects that token exchange with an opaque 400. This
173        // assertion is conditional because whether the pair is present
174        // depends on how the test binary was compiled (CI sets no .env, a
175        // release build does) — the invariant is the all-or-nothing shape.
176        if let Some((id, secret)) = crate::core::config::embedded_oauth_client() {
177            assert!(!id.is_empty(), "id must not be blank");
178            assert!(!secret.is_empty(), "secret must not be blank");
179        }
180    }
181
182    #[test]
183    fn the_no_client_message_names_every_route() {
184        // This string is the first thing a source-build user sees, so it
185        // must point at all three ways out.
186        let help = crate::core::config::NO_CLIENT_HELP;
187        assert!(help.contains("release"));
188        assert!(help.contains(".env"));
189        assert!(help.contains("auth setup"));
190    }
191}