grr_cli/core/
config_loader.rs1use 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
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 Ok(config)
47 }
48
49 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 fn env_provider() -> impl Provider {
75 Env::raw().filter_map(|key| {
76 let key = key.as_str();
77 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 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 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 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 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 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 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 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}