1use std::env;
11#[cfg(test)]
12use std::sync::Mutex;
13use std::sync::{Arc, OnceLock};
14use tokio::sync::RwLock;
15
16#[derive(Debug, Clone)]
25pub struct TokenResolver {
26 allow_gh_fallback: bool,
30 cached: Arc<RwLock<Option<String>>>,
32}
33
34impl TokenResolver {
35 pub fn default_env() -> Self {
37 Self {
38 allow_gh_fallback: true,
39 cached: Arc::new(RwLock::new(None)),
40 }
41 }
42
43 #[cfg(test)]
45 #[must_use]
46 pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
47 self.allow_gh_fallback = enabled;
48 self
49 }
50
51 pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
57 if let Some(ref t) = *self.cached.read().await {
58 return Ok(Some(t.clone()));
59 }
60
61 let token = self.resolve_fresh()?;
62 if let Some(ref t) = token {
63 *self.cached.write().await = Some(t.clone());
64 }
65
66 Ok(token)
67 }
68
69 pub async fn clear_cache(&self) {
71 let mut cached = self.cached.write().await;
72 *cached = None;
73 }
74
75 fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
77 if let Some(t) = Self::resolve_env_token() {
78 return Ok(Some(t));
79 }
80
81 if self.allow_gh_fallback {
83 tracing::debug!("No token in env/config — trying gh auth token CLI");
84 return Self::resolve_gh_cli_token();
85 }
86
87 Ok(None)
88 }
89
90 fn resolve_env_token() -> Option<String> {
97 if let Ok(t) = env::var("GH_TOKEN")
98 && !t.is_empty()
99 {
100 tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
102 return Some(t);
103 }
104 if let Ok(t) = env::var("GITHUB_TOKEN")
105 && !t.is_empty()
106 {
107 tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
108 return Some(t);
109 }
110 if let Ok(t) = crate::config::get("gh.auth.token")
112 && !t.is_empty()
113 {
114 tracing::info!("Resolved GitHub token from gh.auth.token config");
115 return Some(t);
116 }
117 None
118 }
119
120 fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
122 tracing::debug!("Attempting to resolve GitHub token via gh CLI");
123
124 if let Some(token) = Self::try_gh_command("gh")? {
126 return Ok(Some(token));
127 }
128
129 for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
131 if let Some(token) = Self::try_gh_command(path)? {
132 return Ok(Some(token));
133 }
134 }
135
136 Ok(None)
137 }
138
139 #[expect(
153 clippy::unnecessary_wraps,
154 reason = "the gh fallback is settled code, see issues 418 and 421"
155 )]
156 fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
157 match std::process::Command::new(gh_path)
158 .args(["auth", "token"])
159 .output()
160 {
161 Ok(out) if out.status.success() => {
162 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
163 if !token.is_empty() {
164 tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
165 return Ok(Some(token));
166 }
167 }
168 Ok(out) => {
169 let stderr = String::from_utf8_lossy(&out.stderr);
170 tracing::debug!(gh_path, %stderr, "gh auth token failed");
171 }
172 Err(e) => {
173 tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
174 }
175 }
176 Ok(None)
177 }
178}
179
180static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
186
187pub fn shared() -> Arc<TokenResolver> {
189 std::sync::Arc::clone(SHARED_RESOLVER.get_or_init(|| Arc::new(TokenResolver::default_env())))
190}
191
192impl Default for TokenResolver {
193 fn default() -> Self {
194 Self::default_env()
195 }
196}
197
198#[cfg(test)]
200pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(());
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn token_resolver_gh_fallback_enabled_by_default() {
208 let resolver = TokenResolver::default_env();
209 assert!(resolver.allow_gh_fallback);
210 }
211
212 #[test]
213 fn token_resolver_with_gh_fallback() {
214 let resolver = TokenResolver::default_env().with_gh_fallback(false);
215 assert!(!resolver.allow_gh_fallback);
216 }
217
218 #[expect(
219 clippy::await_holding_lock,
220 reason = "the guard keeps env var tests serial across awaits"
221 )]
222 #[tokio::test(flavor = "current_thread")]
223 async fn get_token_returns_none_when_no_env_set() {
224 let _guard = ENV_LOCK.lock().unwrap();
225 unsafe { env::remove_var("GH_TOKEN") };
227 unsafe { env::remove_var("GITHUB_TOKEN") };
229 let resolver = TokenResolver::default_env().with_gh_fallback(false);
231 resolver.clear_cache().await;
232
233 let token = resolver.get_token().await.unwrap();
234 assert!(token.is_none());
235 }
236
237 #[expect(
238 clippy::await_holding_lock,
239 reason = "the guard keeps env var tests serial across awaits"
240 )]
241 #[tokio::test(flavor = "current_thread")]
242 async fn get_token_prefers_gh_token() {
243 let _guard = ENV_LOCK.lock().unwrap();
244 unsafe { env::remove_var("GH_TOKEN") };
246 unsafe { env::remove_var("GITHUB_TOKEN") };
248 let resolver = TokenResolver::default_env().with_gh_fallback(false);
249 resolver.clear_cache().await;
250
251 unsafe { env::set_var("GH_TOKEN", "gh_token_value") };
253 unsafe { env::remove_var("GITHUB_TOKEN") };
255
256 let resolver = TokenResolver::default_env().with_gh_fallback(false);
257 let token = resolver.get_token().await.unwrap();
258 assert_eq!(token, Some("gh_token_value".to_string()));
259
260 unsafe { env::remove_var("GH_TOKEN") };
262 }
263
264 #[expect(
265 clippy::await_holding_lock,
266 reason = "the guard keeps env var tests serial across awaits"
267 )]
268 #[tokio::test(flavor = "current_thread")]
269 async fn get_token_falls_back_to_github_token() {
270 let _guard = ENV_LOCK.lock().unwrap();
271 unsafe { env::remove_var("GH_TOKEN") };
273 unsafe { env::remove_var("GITHUB_TOKEN") };
275 let resolver = TokenResolver::default_env().with_gh_fallback(false);
276 resolver.clear_cache().await;
277
278 unsafe { env::set_var("GITHUB_TOKEN", "github_token_value") };
280
281 let token = resolver.get_token().await.unwrap();
282 assert_eq!(token, Some("github_token_value".to_string()));
283
284 unsafe { env::remove_var("GITHUB_TOKEN") };
286 }
287
288 #[expect(
289 clippy::await_holding_lock,
290 reason = "the guard keeps env var tests serial across awaits"
291 )]
292 #[tokio::test(flavor = "current_thread")]
293 async fn get_token_prefers_gh_token_over_github_token() {
294 let _guard = ENV_LOCK.lock().unwrap();
295 unsafe { env::remove_var("GH_TOKEN") };
297 unsafe { env::remove_var("GITHUB_TOKEN") };
299 let resolver = TokenResolver::default_env().with_gh_fallback(false);
300 resolver.clear_cache().await;
301
302 unsafe { env::set_var("GH_TOKEN", "gh_token_value") };
304 unsafe { env::set_var("GITHUB_TOKEN", "github_token_value") };
306
307 let token = resolver.get_token().await.unwrap();
308 assert_eq!(token, Some("gh_token_value".to_string()));
310
311 unsafe { env::remove_var("GH_TOKEN") };
313 unsafe { env::remove_var("GITHUB_TOKEN") };
315 }
316
317 #[expect(
318 clippy::await_holding_lock,
319 reason = "the guard keeps env var tests serial across awaits"
320 )]
321 #[tokio::test(flavor = "current_thread")]
322 async fn get_token_ignores_empty_env_vars() {
323 let _guard = ENV_LOCK.lock().unwrap();
324 unsafe { env::remove_var("GH_TOKEN") };
326 unsafe { env::remove_var("GITHUB_TOKEN") };
328 let resolver = TokenResolver::default_env().with_gh_fallback(false);
329 resolver.clear_cache().await;
330
331 unsafe { env::set_var("GH_TOKEN", "") };
333 unsafe { env::set_var("GITHUB_TOKEN", "") };
335
336 let token = resolver.get_token().await.unwrap();
337 assert!(token.is_none());
338
339 unsafe { env::remove_var("GH_TOKEN") };
341 unsafe { env::remove_var("GITHUB_TOKEN") };
343 }
344
345 #[expect(
346 clippy::await_holding_lock,
347 reason = "the guard keeps env var tests serial across awaits"
348 )]
349 #[tokio::test(flavor = "current_thread")]
350 async fn clear_cache_clears_token() {
351 let _guard = ENV_LOCK.lock().unwrap();
352 unsafe { env::remove_var("GH_TOKEN") };
354 unsafe { env::remove_var("GITHUB_TOKEN") };
356 let resolver = TokenResolver::default_env().with_gh_fallback(false);
357 resolver.clear_cache().await;
358
359 unsafe { env::set_var("GH_TOKEN", "cached_token") };
361
362 let token1 = resolver.get_token().await.unwrap();
364 assert_eq!(token1, Some("cached_token".to_string()));
365
366 resolver.clear_cache().await;
368
369 let token2 = resolver.get_token().await.unwrap();
371 assert_eq!(token2, Some("cached_token".to_string()));
372
373 unsafe { env::remove_var("GH_TOKEN") };
375 }
376
377 #[test]
378 fn resolve_env_token_returns_none_when_unset() {
379 let _guard = ENV_LOCK.lock().unwrap();
380 unsafe { env::remove_var("GH_TOKEN") };
382 unsafe { env::remove_var("GITHUB_TOKEN") };
384
385 let token = TokenResolver::resolve_env_token();
386 assert!(token.is_none());
387 }
388
389 #[test]
390 fn resolve_env_token_prefers_gh_token() {
391 let _guard = ENV_LOCK.lock().unwrap();
392 unsafe { env::set_var("GH_TOKEN", "gh_pref") };
394 unsafe { env::set_var("GITHUB_TOKEN", "github_pref") };
396
397 let token = TokenResolver::resolve_env_token();
398 assert_eq!(token, Some("gh_pref".to_string()));
399
400 unsafe { env::remove_var("GH_TOKEN") };
402 unsafe { env::remove_var("GITHUB_TOKEN") };
404 }
405}