1use std::fmt::Display;
2use std::process::{Command, ExitStatus};
3
4#[derive(Debug)]
5pub struct ResolveTokenError {
6 kind: ResolveTokenErrorKind,
7}
8
9impl Display for ResolveTokenError {
10 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11 write!(
12 f,
13 "unable to resolve GitHub token\n\n\
14 Provide a token using one of these methods (in order of precedence):\n \
15 1. --github-token <TOKEN>\n \
16 2. GITHUB_TOKEN environment variable\n \
17 3. Install and authenticate the GitHub CLI: gh auth login"
18 )
19 }
20}
21
22impl std::error::Error for ResolveTokenError {
23 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
24 match &self.kind {
25 ResolveTokenErrorKind::Io(err) => Some(err),
26 ResolveTokenErrorKind::NonZeroExit { .. } | ResolveTokenErrorKind::EmptyToken => None,
27 ResolveTokenErrorKind::InvalidUtf8(err) => Some(err),
28 }
29 }
30}
31
32#[derive(Debug)]
33#[allow(dead_code)]
34enum ResolveTokenErrorKind {
35 Io(std::io::Error),
36 NonZeroExit { status: ExitStatus, stderr: String },
37 EmptyToken,
38 InvalidUtf8(std::string::FromUtf8Error),
39}
40
41fn build_gh_token_args(hostname: &str) -> Vec<String> {
42 vec![
43 "auth".to_string(),
44 "token".to_string(),
45 "--hostname".to_string(),
46 hostname.to_string(),
47 ]
48}
49
50pub fn resolve_token_from_gh_cli(hostname: &str) -> Result<String, ResolveTokenError> {
52 let args = build_gh_token_args(hostname);
53 let output = Command::new("gh")
54 .args(&args)
55 .output()
56 .map_err(|err| ResolveTokenError {
57 kind: ResolveTokenErrorKind::Io(err),
58 })?;
59
60 parse_gh_output(output.status, &output.stdout, &output.stderr)
61}
62
63fn parse_gh_output(
64 status: ExitStatus,
65 stdout: &[u8],
66 stderr: &[u8],
67) -> Result<String, ResolveTokenError> {
68 if !status.success() {
69 let stderr = String::from_utf8_lossy(stderr).into_owned();
70 return Err(ResolveTokenError {
71 kind: ResolveTokenErrorKind::NonZeroExit { status, stderr },
72 });
73 }
74
75 let token = String::from_utf8(stdout.to_vec()).map_err(|err| ResolveTokenError {
76 kind: ResolveTokenErrorKind::InvalidUtf8(err),
77 })?;
78 let token = token.trim().to_string();
79
80 if token.is_empty() {
81 return Err(ResolveTokenError {
82 kind: ResolveTokenErrorKind::EmptyToken,
83 });
84 }
85
86 Ok(token)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[cfg(unix)]
94 fn exit_status(code: i32) -> ExitStatus {
95 use std::os::unix::process::ExitStatusExt;
96 ExitStatus::from_raw(code << 8)
97 }
98
99 #[cfg(unix)]
100 #[test]
101 fn successful_output_returns_trimmed_token() {
102 let result = parse_gh_output(exit_status(0), b"gho_abc123\n", b"");
103 assert_eq!(result.unwrap(), "gho_abc123");
104 }
105
106 #[cfg(unix)]
107 #[test]
108 fn trailing_whitespace_is_trimmed() {
109 let result = parse_gh_output(exit_status(0), b" gho_abc123 \n", b"");
110 assert_eq!(result.unwrap(), "gho_abc123");
111 }
112
113 #[cfg(unix)]
114 #[test]
115 fn non_zero_exit_returns_error() {
116 let result = parse_gh_output(exit_status(1), b"", b"not logged in");
117 assert!(result.is_err());
118 }
119
120 #[cfg(unix)]
121 #[test]
122 fn empty_stdout_returns_error() {
123 let result = parse_gh_output(exit_status(0), b"", b"");
124 assert!(result.is_err());
125 }
126
127 #[cfg(unix)]
128 #[test]
129 fn whitespace_only_stdout_returns_error() {
130 let result = parse_gh_output(exit_status(0), b" \n ", b"");
131 assert!(result.is_err());
132 }
133
134 #[test]
135 fn error_display_message() {
136 let err = ResolveTokenError {
137 kind: ResolveTokenErrorKind::EmptyToken,
138 };
139 insta::assert_snapshot!(err.to_string());
140 }
141
142 #[test]
143 fn build_args_for_github_com() {
144 let args = build_gh_token_args("github.com");
145 assert_eq!(args, ["auth", "token", "--hostname", "github.com"]);
146 }
147
148 #[test]
149 fn build_args_for_github_enterprise() {
150 let args = build_gh_token_args("github.mycompany.com");
151 assert_eq!(
152 args,
153 ["auth", "token", "--hostname", "github.mycompany.com"]
154 );
155 }
156}