Skip to main content

git_disjoint/
token.rs

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
50/// Resolve a GitHub token by invoking `gh auth token --hostname <hostname>`.
51pub 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}