gh_config/
lib.rs

1//! # gh-config
2//! Loads config and hosts for gh CLI.
3//!
4//! ## Getting started
5//! ```toml
6//! [dependencies]
7//! gh-config = "0.3"
8//! ```
9//!
10//! ## Usage
11//! ```rust
12//! use std::error::Error;
13//! use gh_config::*;
14//!
15//! fn main() -> Result<(), Box<dyn Error>> {
16//!     let config = Config::load()?;
17//!     let hosts = Hosts::load()?;
18//!     
19//!     match hosts.get(GITHUB_COM) {
20//!         Some(host) => println!("Token for github.com: {}", hosts.retrieve_token(GITHUB_COM)?.unwrap()),
21//!         _ => eprintln!("Token not found."),
22//!     }
23//!
24//!     Ok(())
25//! }
26//! ```
27
28mod keyring;
29
30use std::collections::HashMap;
31use std::env::var;
32use std::path::{Path, PathBuf};
33
34use dirs::home_dir;
35use serde::{Deserialize, Serialize};
36
37use crate::keyring::{GhKeyring, Keyring};
38
39#[cfg(target_os = "windows")]
40const APP_DATA: &str = "AppData";
41const GH_CONFIG_DIR: &str = "GH_CONFIG_DIR";
42const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME";
43
44const CONFIG_FILE_NAME: &str = "config.yml";
45const HOSTS_FILE_NAME: &str = "hosts.yml";
46
47/// Hostname of github.com.
48pub const GITHUB_COM: &str = "github.com";
49pub const GHE_COM: &str = "ghe.com";
50pub const LOCALHOST: &str = "github.localhost";
51
52/// An error occurred in this crate.
53#[derive(Debug, thiserror::Error)]
54pub enum Error {
55    #[error("Failed to deserialize config from YAML: {0}")]
56    Yaml(#[from] serde_yaml::Error),
57
58    #[error("I/O error: {0}")]
59    Io(#[from] std::io::Error),
60
61    #[error("Secure storage error: {0}")]
62    Keyring(#[from] keyring::Error),
63
64    #[error("Config file not found.")]
65    ConfigNotFound,
66}
67
68/// What protocol to use when performing git operations.
69#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum GitProtocol {
72    Https,
73    Ssh,
74}
75
76/// When to interactively prompt.
77#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum Prompt {
80    Enabled,
81    Disabled,
82}
83
84impl From<Prompt> for bool {
85    fn from(p: Prompt) -> Self {
86        matches!(p, Prompt::Enabled)
87    }
88}
89
90/// Config representation for gh CLI.
91#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct Config {
93    /// What protocol to use when performing git operations.
94    pub git_protocol: GitProtocol,
95
96    /// What editor gh should run when creating issues, pull requests, etc.
97    /// If blank, will refer to environment.
98    pub editor: Option<String>,
99
100    /// When to interactively prompt.
101    /// This is a global config that cannot be overridden by hostname.
102    pub prompt: Prompt,
103
104    /// A pager program to send command output to, e.g. "less".
105    /// Set the value to "cat" to disable the pager.
106    pub pager: Option<String>,
107
108    /// Aliases allow you to create nicknames for gh commands.
109    #[serde(default)]
110    pub aliases: HashMap<String, String>,
111
112    /// The path to a unix socket through which send HTTP connections.
113    /// If blank, HTTP traffic will be handled by default transport.
114    pub http_unix_socket: Option<String>,
115
116    /// What web browser gh should use when opening URLs.
117    /// If blank, will refer to environment.
118    pub browser: Option<String>,
119}
120
121impl Config {
122    /// Loads a config from the default path.
123    pub fn load() -> Result<Self, Error> {
124        Self::load_from(CONFIG_FILE_NAME)
125    }
126
127    /// Loads all host configs from the specified path.
128    pub fn load_from<P>(path: P) -> Result<Self, Error>
129    where
130        P: AsRef<Path>,
131    {
132        load(path)
133    }
134}
135
136/// Host config representation for gh CLI.
137#[derive(Debug, Clone, Deserialize, Serialize)]
138pub struct Host {
139    pub user: Option<String>,
140    #[serde(default)]
141    oauth_token: String,
142    pub git_protocol: Option<GitProtocol>,
143}
144
145/// Mapped host configs by their hostname.
146#[derive(Debug, Clone, Deserialize, Serialize)]
147pub struct Hosts(HashMap<String, Host>);
148
149impl Hosts {
150    /// Loads all host configs from the default path.
151    pub fn load() -> Result<Self, Error> {
152        Self::load_from(HOSTS_FILE_NAME)
153    }
154
155    /// Loads all host configs from the specified path.
156    pub fn load_from<P>(path: P) -> Result<Self, Error>
157    where
158        P: AsRef<Path>,
159    {
160        load(path).map(Self)
161    }
162
163    /// Gets a host config by the hostname.
164    pub fn get(&self, hostname: &str) -> Option<&Host> {
165        self.0.get(hostname)
166    }
167
168    /// Sets a host config and returns the current value.
169    /// If no values present currently, returns `None` .
170    pub fn set(&mut self, hostname: impl Into<String>, host: Host) -> Option<Host> {
171        self.0.insert(hostname.into(), host)
172    }
173
174    /// Retrieves a token from the environment variables, the hosts file, or the secure storage.
175    /// User interaction may be required to unlock the keychain, depending on the OS.
176    /// If any token found for the hostname, returns None.
177    pub fn retrieve_token(&self, hostname: &str) -> Result<Option<String>, Error> {
178        if let Some(token) = retrieve_token_from_env(is_enterprise(hostname)) {
179            return Ok(Some(token));
180        }
181
182        if let Some(token) = self
183            .get(hostname)
184            .and_then(|h| match h.oauth_token.is_empty() {
185                true => None,
186                _ => Some(h.oauth_token.to_owned()),
187            })
188        {
189            return Ok(Some(token));
190        }
191
192        retrieve_token_secure(hostname)
193    }
194
195    /// Retrieves a token from the secure storage only.
196    /// User interaction may be required to unlock the keychain, depending on the OS.
197    /// If any token found for the hostname, returns None.
198    #[deprecated(
199        since = "0.4.0",
200        note = "Use `retrieve_token_secure` without `Hosts` struct instead."
201    )]
202    pub fn retrieve_token_secure(&self, hostname: &str) -> Result<Option<String>, Error> {
203        retrieve_token_secure(hostname)
204    }
205}
206
207/// Determines the provided hostname is a GitHub Enterprise Server instance or not.
208pub fn is_enterprise(host: &str) -> bool {
209    host != GITHUB_COM && host != LOCALHOST && !host.ends_with(&format!(".{}", GHE_COM))
210}
211
212/// Retrieves a token from the environment variables `GH_TOKEN` or `GITHUB_TOKEN`.
213/// Also tries to retrieve from `GH_ENTERPRISE_TOKEN` or `GITHUB_ENTERPRISE_TOKEN`, if the
214/// enterprise flag enabled.
215pub fn retrieve_token_from_env(enterprise: bool) -> Option<String> {
216    if enterprise {
217        if let Ok(token) = var("GH_ENTERPRISE_TOKEN").or_else(|_| var("GITHUB_ENTERPRISE_TOKEN")) {
218            return Some(token);
219        }
220    }
221
222    var("GH_TOKEN").or_else(|_| var("GITHUB_TOKEN")).ok()
223}
224
225/// Retrieves a token from the secure storage.
226/// User interaction may be required to unlock the keychain, depending on the OS.
227/// If any token found for the hostname, returns None.
228pub fn retrieve_token_secure(hostname: &str) -> Result<Option<String>, Error> {
229    Ok(Keyring
230        .get(hostname)?
231        .map(|t| String::from_utf8(t).unwrap()))
232}
233
234/// Finds the default config directory effected by the environment.
235pub fn find_config_directory() -> Option<PathBuf> {
236    let gh_config_dir = var(GH_CONFIG_DIR).unwrap_or_default();
237    if !gh_config_dir.is_empty() {
238        return Some(PathBuf::from(gh_config_dir));
239    }
240
241    let xdg_config_home = var(XDG_CONFIG_HOME).unwrap_or_default();
242    if !xdg_config_home.is_empty() {
243        return Some(PathBuf::from(xdg_config_home).join("gh"));
244    }
245
246    #[cfg(target_os = "windows")]
247    {
248        let app_data = var(APP_DATA).unwrap_or_default();
249        if !app_data.is_empty() {
250            return Some(PathBuf::from(app_data).join("GitHub CLI"));
251        }
252    }
253
254    home_dir().map(|p| p.join(".config").join("gh"))
255}
256
257/// Loads a file in the config directory as `T` type.
258pub fn load<T, P>(path: P) -> Result<T, Error>
259where
260    T: for<'de> Deserialize<'de>,
261    P: AsRef<Path>,
262{
263    serde_yaml::from_slice(
264        std::fs::read(
265            find_config_directory()
266                .ok_or(Error::ConfigNotFound)?
267                .join(path),
268        )
269        .map_err(Error::Io)?
270        .as_ref(),
271    )
272    .map_err(Error::Yaml)
273}