1use std::env;
11use std::process::Command;
12#[cfg(test)]
13use std::sync::Mutex;
14use std::sync::{Arc, OnceLock};
15use tokio::sync::RwLock;
16
17use crate::config;
18
19#[derive(Debug, Clone)]
28pub struct TokenResolver {
29 allow_gh_fallback: bool,
33 cached: Arc<RwLock<Option<String>>>,
35}
36
37impl TokenResolver {
38 pub fn default_env() -> Self {
40 Self {
41 allow_gh_fallback: true,
42 cached: Arc::new(RwLock::new(None)),
43 }
44 }
45
46 #[cfg(test)]
48 #[must_use]
49 pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
50 self.allow_gh_fallback = enabled;
51 self
52 }
53
54 pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
60 if let Some(ref t) = *self.cached.read().await {
61 return Ok(Some(t.clone()));
62 }
63
64 let token = self.resolve_fresh()?;
65 if let Some(ref t) = token {
66 *self.cached.write().await = Some(t.clone());
67 }
68
69 Ok(token)
70 }
71
72 pub async fn clear_cache(&self) {
74 let mut cached = self.cached.write().await;
75 *cached = None;
76 }
77
78 fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
80 if let Some(t) = Self::resolve_env_token() {
81 return Ok(Some(t));
82 }
83
84 if self.allow_gh_fallback {
86 tracing::debug!("No token in env/config — trying gh auth token CLI");
87 return Self::resolve_gh_cli_token();
88 }
89
90 Ok(None)
91 }
92
93 fn resolve_env_token() -> Option<String> {
100 if let Ok(t) = env::var("GH_TOKEN")
101 && !t.is_empty()
102 {
103 tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
105 return Some(t);
106 }
107 if let Ok(t) = env::var("GITHUB_TOKEN")
108 && !t.is_empty()
109 {
110 tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
111 return Some(t);
112 }
113 if let Ok(t) = config::get("gh.auth.token")
115 && !t.is_empty()
116 {
117 tracing::info!("Resolved GitHub token from gh.auth.token config");
118 return Some(t);
119 }
120 None
121 }
122
123 fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
125 tracing::debug!("Attempting to resolve GitHub token via gh CLI");
126
127 if let Some(token) = Self::try_gh_command("gh")? {
129 return Ok(Some(token));
130 }
131
132 for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
134 if let Some(token) = Self::try_gh_command(path)? {
135 return Ok(Some(token));
136 }
137 }
138
139 Ok(None)
140 }
141
142 #[expect(
156 clippy::unnecessary_wraps,
157 reason = "the gh fallback is settled code, see issues 418 and 421"
158 )]
159 fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
160 match Command::new(gh_path).args(["auth", "token"]).output() {
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 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}