grr_cli/core/
config_loader.rs1use crate::core::config::{GrrConfig, embedded_device_client, 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
13pub struct ConfigLoader;
15
16impl ConfigLoader {
17 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 match config.oauth.device_client_id.as_deref().map(str::trim) {
47 Some(id) if !id.is_empty() => info!("device-flow client: configured"),
48 _ => info!(
49 "device-flow client: absent (`grr auth login --device` explains the client type Google requires)"
50 ),
51 }
52 Ok(config)
53 }
54
55 fn apply_embedded_default(config: GrrConfig) -> GrrConfig {
63 let config = Self::apply_client(config, embedded_oauth_client());
64 Self::apply_device_client(config, embedded_device_client())
65 }
66
67 fn apply_client(mut config: GrrConfig, embedded: Option<(&str, &str)>) -> GrrConfig {
68 if !config.oauth.client_id.trim().is_empty() {
69 return config;
70 }
71
72 let Some((client_id, client_secret)) = embedded else {
73 return config;
74 };
75
76 info!("Using the OAuth client built into this binary");
77 config.oauth.client_id = client_id.to_owned();
78 config.oauth.client_secret = Some(client_secret.to_owned());
79 config
80 }
81
82 fn apply_device_client(mut config: GrrConfig, embedded: Option<(&str, &str)>) -> GrrConfig {
85 let explicit = config
86 .oauth
87 .device_client_id
88 .as_deref()
89 .map(str::trim)
90 .is_some_and(|id| !id.is_empty());
91 if explicit {
92 return config;
93 }
94
95 let Some((client_id, client_secret)) = embedded else {
96 return config;
97 };
98
99 info!("Using the device-flow OAuth client built into this binary");
100 config.oauth.device_client_id = Some(client_id.to_owned());
101 config.oauth.device_client_secret = Some(client_secret.to_owned());
102 config
103 }
104
105 fn env_provider() -> impl Provider {
108 Env::raw().filter_map(|key| {
109 let key = key.as_str();
110 if key.eq_ignore_ascii_case("TYPESAFE_API_KEY") {
114 return Some("systemone.api-key".into());
115 }
116 if let Some(stripped) = key.strip_prefix("GRR_") {
117 let key = stripped.replace("__", ".");
120 let key = key.replace('_', "-");
121 let key = key.to_ascii_lowercase();
122 Some(key.into())
123 } else {
124 None
125 }
126 })
127 }
128
129 pub fn config_path() -> Result<PathBuf> {
138 if let Ok(path) = std::env::var("GRR_CONFIG_PATH") {
139 return Ok(PathBuf::from(path));
140 }
141
142 let home = dirs::home_dir()
143 .ok_or_else(|| GrrError::Config("Could not find home directory".into()))?;
144
145 Ok(home.join(".grr").join("config.toml"))
146 }
147}
148
149#[cfg(test)]
150mod resolution_order_tests {
151 use super::*;
152 use crate::core::config::OAuthConfig;
153
154 fn config_with(id: &str, secret: Option<&str>) -> GrrConfig {
155 GrrConfig {
156 oauth: OAuthConfig {
157 client_id: id.to_owned(),
158 client_secret: secret.map(str::to_owned),
159 ..OAuthConfig::default()
160 },
161 ..GrrConfig::default()
162 }
163 }
164
165 const EMBEDDED: (&str, &str) = ("embedded-id.apps.googleusercontent.com", "embedded-secret");
166
167 #[test]
168 fn embedded_client_fills_an_empty_config() {
169 let resolved = ConfigLoader::apply_client(config_with("", None), Some(EMBEDDED));
170 assert_eq!(resolved.oauth.client_id, EMBEDDED.0);
171 assert_eq!(resolved.oauth.client_secret.as_deref(), Some(EMBEDDED.1));
172 }
173
174 #[test]
175 fn a_configured_client_id_is_never_overwritten() {
176 let resolved = ConfigLoader::apply_client(
179 config_with("mine.apps.googleusercontent.com", None),
180 Some(EMBEDDED),
181 );
182 assert_eq!(resolved.oauth.client_id, "mine.apps.googleusercontent.com");
183 assert!(resolved.oauth.client_secret.is_none());
184 }
185
186 #[test]
187 fn a_whitespace_only_client_id_still_falls_back_to_embedded() {
188 let resolved = ConfigLoader::apply_client(config_with(" ", None), Some(EMBEDDED));
191 assert_eq!(resolved.oauth.client_id, EMBEDDED.0);
192 }
193
194 #[test]
195 fn without_an_embedded_client_the_config_is_left_alone() {
196 let resolved = ConfigLoader::apply_client(config_with("", None), None);
199 assert!(resolved.oauth.client_id.is_empty());
200 assert!(resolved.oauth.client_secret.is_none());
201 }
202
203 #[test]
204 fn embedded_defaults_are_all_or_nothing() {
205 if let Some((id, secret)) = crate::core::config::embedded_oauth_client() {
211 assert!(!id.is_empty(), "id must not be blank");
212 assert!(!secret.is_empty(), "secret must not be blank");
213 }
214 }
215
216 #[test]
217 fn the_no_client_message_names_every_route() {
218 let help = crate::core::config::NO_CLIENT_HELP;
221 assert!(help.contains("release"));
222 assert!(help.contains(".env"));
223 assert!(help.contains("auth setup"));
224 }
225}