1#![doc(html_root_url = "https://docs.rs/gh-token/0.1.8")]
2#![allow(
3 clippy::missing_errors_doc,
4 clippy::module_name_repetitions,
5 clippy::uninlined_format_args
6)]
7
8use crate::error::ParseError;
9use serde_derive::Deserialize;
10use std::env;
11use std::fmt::{self, Debug, Display};
12use std::fs;
13use std::io::ErrorKind;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17#[derive(Deserialize)]
18struct Config {
19 #[serde(rename = "github.com")]
20 github_com: Option<Host>,
21}
22
23#[derive(Deserialize)]
24struct Host {
25 oauth_token: Option<String>,
26}
27
28pub enum Error {
29 NotConfigured(PathBuf),
30 Parse(error::ParseError),
31}
32
33mod error {
34 use std::io;
35 use std::path::PathBuf;
36
37 pub enum ParseError {
38 EnvNonUtf8(&'static str),
39 Io(PathBuf, io::Error),
40 Yaml(PathBuf, serde_yaml::Error),
41 }
42}
43
44impl std::error::Error for Error {}
45
46impl Display for Error {
47 fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
48 match self {
49 Error::NotConfigured(path) => {
50 write!(
51 formatter,
52 "no github.com token found in {}; use `gh auth login` to authenticate",
53 path.display(),
54 )
55 }
56 Error::Parse(ParseError::EnvNonUtf8(var)) => {
57 write!(
58 formatter,
59 "environment variable ${} contains non-utf8 value",
60 var,
61 )
62 }
63 Error::Parse(ParseError::Io(path, io_error)) => {
64 write!(formatter, "failed to read {}: {}", path.display(), io_error)
65 }
66 Error::Parse(ParseError::Yaml(path, yaml_error)) => {
67 write!(
68 formatter,
69 "failed to parse {}: {}",
70 path.display(),
71 yaml_error,
72 )
73 }
74 }
75 }
76}
77
78impl Debug for Error {
79 fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
80 write!(formatter, "Error({:?})", self.to_string())
81 }
82}
83
84pub fn get() -> Result<String, Error> {
85 for var in ["GH_TOKEN", "GITHUB_TOKEN"] {
86 if let Some(token_from_env) = env::var_os(var) {
87 return token_from_env
88 .into_string()
89 .map_err(|_| Error::Parse(ParseError::EnvNonUtf8(var)));
90 }
91 }
92
93 let Some(path) = hosts_config_file() else {
94 let fallback_path = Path::new("~").join(".config").join("gh").join("hosts.yml");
95 return Err(Error::NotConfigured(fallback_path));
96 };
97
98 let content = match fs::read(&path) {
99 Ok(content) => content,
100 Err(io_error) => {
101 return Err(if io_error.kind() == ErrorKind::NotFound {
102 Error::NotConfigured(path)
103 } else {
104 Error::Parse(ParseError::Io(path, io_error))
105 });
106 }
107 };
108
109 let config: Config = match serde_yaml::from_slice(&content) {
110 Ok(config) => config,
111 Err(yaml_error) => return Err(Error::Parse(ParseError::Yaml(path, yaml_error))),
112 };
113
114 if let Some(github_com) = config.github_com {
115 if let Some(oauth_token) = github_com.oauth_token {
116 return Ok(oauth_token);
117 }
118 }
119
120 if let Some(token) = token_from_cli() {
127 return Ok(token);
128 }
129
130 Err(Error::NotConfigured(path))
134}
135
136fn hosts_config_file() -> Option<PathBuf> {
137 let config_dir = config_dir()?;
138 Some(config_dir.join("hosts.yml"))
139}
140
141fn config_dir() -> Option<PathBuf> {
142 if let Some(gh_config_dir) = env::var_os("GH_CONFIG_DIR") {
143 if !gh_config_dir.is_empty() {
144 return Some(PathBuf::from(gh_config_dir));
145 }
146 }
147
148 if let Some(xdg_config_home) = env::var_os("XDG_CONFIG_HOME") {
149 if !xdg_config_home.is_empty() {
150 return Some(Path::new(&xdg_config_home).join("gh"));
151 }
152 }
153
154 if cfg!(windows) {
155 if let Some(app_data) = env::var_os("AppData") {
156 if !app_data.is_empty() {
157 return Some(Path::new(&app_data).join("GitHub CLI"));
158 }
159 }
160 }
161
162 let home_dir = home::home_dir()?;
163 Some(home_dir.join(".config").join("gh"))
164}
165
166fn token_from_cli() -> Option<String> {
167 let output = Command::new("gh").arg("auth").arg("token").output().ok()?;
168 let mut token = String::from_utf8(output.stdout).ok()?;
169 let token_len = token.trim_end().len();
171 token.truncate(token_len);
172 Some(token)
173}