1#[cfg(test)]
11use once_cell::sync::Lazy;
12use std::env;
13#[cfg(test)]
14use std::sync::Mutex;
15use std::sync::{Arc, OnceLock};
16use tokio::sync::RwLock;
17
18#[derive(Debug, Clone)]
27pub struct TokenResolver {
28 allow_gh_fallback: bool,
32 cached: Arc<RwLock<Option<String>>>,
34}
35
36impl TokenResolver {
37 pub fn default_env() -> Self {
39 Self {
40 allow_gh_fallback: true,
41 cached: Arc::new(RwLock::new(None)),
42 }
43 }
44
45 #[allow(dead_code)]
47 pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
48 self.allow_gh_fallback = enabled;
49 self
50 }
51
52 pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
58 if let Some(ref t) = *self.cached.read().await {
59 return Ok(Some(t.clone()));
60 }
61
62 let token = self.resolve_fresh()?;
63 if let Some(ref t) = token {
64 *self.cached.write().await = Some(t.clone());
65 }
66
67 Ok(token)
68 }
69
70 pub async fn clear_cache(&self) {
72 let mut cached = self.cached.write().await;
73 *cached = None;
74 }
75
76 fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
78 if let Some(t) = Self::resolve_env_token() {
79 return Ok(Some(t));
80 }
81
82 if self.allow_gh_fallback {
84 tracing::debug!("No token in env/config — trying gh auth token CLI");
85 return Self::resolve_gh_cli_token();
86 }
87
88 Ok(None)
89 }
90
91 fn resolve_env_token() -> Option<String> {
98 if let Ok(t) = env::var("GH_TOKEN") {
99 if !t.is_empty() {
100 tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
102 return Some(t);
103 }
104 }
105 if let Ok(t) = env::var("GITHUB_TOKEN") {
106 if !t.is_empty() {
107 tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
108 return Some(t);
109 }
110 }
111 if let Ok(t) = crate::config::get("gh.auth.token") {
113 if !t.is_empty() {
114 tracing::info!("Resolved GitHub token from gh.auth.token config");
115 return Some(t);
116 }
117 }
118 None
119 }
120
121 fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
123 tracing::debug!("Attempting to resolve GitHub token via gh CLI");
124
125 if let Some(token) = Self::try_gh_command("gh")? {
127 return Ok(Some(token));
128 }
129
130 for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
132 if let Some(token) = Self::try_gh_command(path)? {
133 return Ok(Some(token));
134 }
135 }
136
137 Ok(None)
138 }
139
140 fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
154 match std::process::Command::new(gh_path)
155 .args(["auth", "token"])
156 .output()
157 {
158 Ok(out) if out.status.success() => {
159 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
160 if !token.is_empty() {
161 tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
162 return Ok(Some(token));
163 }
164 }
165 Ok(out) => {
166 let stderr = String::from_utf8_lossy(&out.stderr);
167 tracing::debug!(gh_path, %stderr, "gh auth token failed");
168 }
169 Err(e) => {
170 tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
171 }
172 }
173 Ok(None)
174 }
175}
176
177static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
183
184pub fn shared() -> Arc<TokenResolver> {
186 SHARED_RESOLVER
187 .get_or_init(|| Arc::new(TokenResolver::default_env()))
188 .clone()
189}
190
191impl Default for TokenResolver {
192 fn default() -> Self {
193 Self::default_env()
194 }
195}
196
197#[cfg(test)]
199pub(crate) static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn token_resolver_gh_fallback_enabled_by_default() {
207 let resolver = TokenResolver::default_env();
208 assert!(resolver.allow_gh_fallback);
209 }
210
211 #[test]
212 fn token_resolver_with_gh_fallback() {
213 let resolver = TokenResolver::default_env().with_gh_fallback(false);
214 assert!(!resolver.allow_gh_fallback);
215 }
216
217 #[allow(clippy::await_holding_lock)]
218 #[tokio::test(flavor = "current_thread")]
219 async fn get_token_returns_none_when_no_env_set() {
220 let _guard = ENV_LOCK.lock().unwrap();
221 env::remove_var("GH_TOKEN");
222 env::remove_var("GITHUB_TOKEN");
223 let resolver = TokenResolver::default_env().with_gh_fallback(false);
225 resolver.clear_cache().await;
226
227 let token = resolver.get_token().await.unwrap();
228 assert!(token.is_none());
229 }
230
231 #[allow(clippy::await_holding_lock)]
232 #[tokio::test(flavor = "current_thread")]
233 async fn get_token_prefers_gh_token() {
234 let _guard = ENV_LOCK.lock().unwrap();
235 env::remove_var("GH_TOKEN");
236 env::remove_var("GITHUB_TOKEN");
237 let resolver = TokenResolver::default_env().with_gh_fallback(false);
238 resolver.clear_cache().await;
239
240 env::set_var("GH_TOKEN", "gh_token_value");
241 env::remove_var("GITHUB_TOKEN");
242
243 let resolver = TokenResolver::default_env().with_gh_fallback(false);
244 let token = resolver.get_token().await.unwrap();
245 assert_eq!(token, Some("gh_token_value".to_string()));
246
247 env::remove_var("GH_TOKEN");
248 }
249
250 #[allow(clippy::await_holding_lock)]
251 #[tokio::test(flavor = "current_thread")]
252 async fn get_token_falls_back_to_github_token() {
253 let _guard = ENV_LOCK.lock().unwrap();
254 env::remove_var("GH_TOKEN");
255 env::remove_var("GITHUB_TOKEN");
256 let resolver = TokenResolver::default_env().with_gh_fallback(false);
257 resolver.clear_cache().await;
258
259 env::set_var("GITHUB_TOKEN", "github_token_value");
260
261 let token = resolver.get_token().await.unwrap();
262 assert_eq!(token, Some("github_token_value".to_string()));
263
264 env::remove_var("GITHUB_TOKEN");
265 }
266
267 #[allow(clippy::await_holding_lock)]
268 #[tokio::test(flavor = "current_thread")]
269 async fn get_token_prefers_gh_token_over_github_token() {
270 let _guard = ENV_LOCK.lock().unwrap();
271 env::remove_var("GH_TOKEN");
272 env::remove_var("GITHUB_TOKEN");
273 let resolver = TokenResolver::default_env().with_gh_fallback(false);
274 resolver.clear_cache().await;
275
276 env::set_var("GH_TOKEN", "gh_token_value");
277 env::set_var("GITHUB_TOKEN", "github_token_value");
278
279 let token = resolver.get_token().await.unwrap();
280 assert_eq!(token, Some("gh_token_value".to_string()));
282
283 env::remove_var("GH_TOKEN");
284 env::remove_var("GITHUB_TOKEN");
285 }
286
287 #[allow(clippy::await_holding_lock)]
288 #[tokio::test(flavor = "current_thread")]
289 async fn get_token_ignores_empty_env_vars() {
290 let _guard = ENV_LOCK.lock().unwrap();
291 env::remove_var("GH_TOKEN");
292 env::remove_var("GITHUB_TOKEN");
293 let resolver = TokenResolver::default_env().with_gh_fallback(false);
294 resolver.clear_cache().await;
295
296 env::set_var("GH_TOKEN", "");
297 env::set_var("GITHUB_TOKEN", "");
298
299 let token = resolver.get_token().await.unwrap();
300 assert!(token.is_none());
301
302 env::remove_var("GH_TOKEN");
303 env::remove_var("GITHUB_TOKEN");
304 }
305
306 #[allow(clippy::await_holding_lock)]
307 #[tokio::test(flavor = "current_thread")]
308 async fn clear_cache_clears_token() {
309 let _guard = ENV_LOCK.lock().unwrap();
310 env::remove_var("GH_TOKEN");
311 env::remove_var("GITHUB_TOKEN");
312 let resolver = TokenResolver::default_env().with_gh_fallback(false);
313 resolver.clear_cache().await;
314
315 env::set_var("GH_TOKEN", "cached_token");
316
317 let token1 = resolver.get_token().await.unwrap();
319 assert_eq!(token1, Some("cached_token".to_string()));
320
321 resolver.clear_cache().await;
323
324 let token2 = resolver.get_token().await.unwrap();
326 assert_eq!(token2, Some("cached_token".to_string()));
327
328 env::remove_var("GH_TOKEN");
329 }
330
331 #[test]
332 fn resolve_env_token_returns_none_when_unset() {
333 let _guard = ENV_LOCK.lock().unwrap();
334 env::remove_var("GH_TOKEN");
335 env::remove_var("GITHUB_TOKEN");
336
337 let token = TokenResolver::resolve_env_token();
338 assert!(token.is_none());
339 }
340
341 #[test]
342 fn resolve_env_token_prefers_gh_token() {
343 let _guard = ENV_LOCK.lock().unwrap();
344 env::set_var("GH_TOKEN", "gh_pref");
345 env::set_var("GITHUB_TOKEN", "github_pref");
346
347 let token = TokenResolver::resolve_env_token();
348 assert_eq!(token, Some("gh_pref".to_string()));
349
350 env::remove_var("GH_TOKEN");
351 env::remove_var("GITHUB_TOKEN");
352 }
353}