1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
//! # gh-config
//! Loads config and hosts for gh CLI.
//!
//! ## Getting started
//! ```toml
//! [dependencies]
//! gh-config = "0.3"
//! ```
//!
//! ## Usage
//! ```rust
//! use std::error::Error;
//! use gh_config::*;
//!
//! fn main() -> Result<(), Box<dyn Error>> {
//!     let config = Config::load()?;
//!     let hosts = Hosts::load()?;
//!     
//!     match hosts.get(GITHUB_COM) {
//!         Some(host) => println!("Token for github.com: {}", hosts.retrieve_token(GITHUB_COM)?.unwrap()),
//!         _ => eprintln!("Token not found."),
//!     }
//!
//!     Ok(())
//! }
//! ```

mod keyring;

use std::collections::HashMap;
use std::env::var;
use std::path::{Path, PathBuf};

use dirs::home_dir;
use serde::{Deserialize, Serialize};

use crate::keyring::{GhKeyring, Keyring};

#[cfg(target_os = "windows")]
const APP_DATA: &str = "AppData";
const GH_CONFIG_DIR: &str = "GH_CONFIG_DIR";
const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME";

const CONFIG_FILE_NAME: &str = "config.yml";
const HOSTS_FILE_NAME: &str = "hosts.yml";

/// Hostname of github.com.
pub const GITHUB_COM: &str = "github.com";

/// An error occurred in this crate.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("Failed to deserialize config from YAML: {0}")]
    Yaml(#[from] serde_yaml::Error),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Secure storage error: {0}")]
    Keyring(#[from] keyring::Error),

    #[error("Config file not found.")]
    ConfigNotFound,
}

/// What protocol to use when performing git operations.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GitProtocol {
    Https,
    Ssh,
}

/// When to interactively prompt.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Prompt {
    Enabled,
    Disabled,
}

impl From<Prompt> for bool {
    fn from(p: Prompt) -> Self {
        matches!(p, Prompt::Enabled)
    }
}

/// Config representation for gh CLI.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
    /// What protocol to use when performing git operations.
    pub git_protocol: GitProtocol,

    /// What editor gh should run when creating issues, pull requests, etc.
    /// If blank, will refer to environment.
    pub editor: Option<String>,

    /// When to interactively prompt.
    /// This is a global config that cannot be overridden by hostname.
    pub prompt: Prompt,

    /// A pager program to send command output to, e.g. "less".
    /// Set the value to "cat" to disable the pager.
    pub pager: Option<String>,

    /// Aliases allow you to create nicknames for gh commands.
    #[serde(default)]
    pub aliases: HashMap<String, String>,

    /// The path to a unix socket through which send HTTP connections.
    /// If blank, HTTP traffic will be handled by default transport.
    pub http_unix_socket: Option<String>,

    /// What web browser gh should use when opening URLs.
    /// If blank, will refer to environment.
    pub browser: Option<String>,
}

impl Config {
    /// Loads a config from the default path.
    pub fn load() -> Result<Self, Error> {
        Self::load_from(CONFIG_FILE_NAME)
    }

    /// Loads all host configs from the specified path.
    pub fn load_from<P>(path: P) -> Result<Self, Error>
    where
        P: AsRef<Path>,
    {
        load(path)
    }
}

/// Host config representation for gh CLI.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Host {
    pub user: Option<String>,
    #[serde(default)]
    oauth_token: String,
    pub git_protocol: Option<GitProtocol>,
}

/// Mapped host configs by their hostname.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Hosts(HashMap<String, Host>);

impl Hosts {
    /// Loads all host configs from the default path.
    pub fn load() -> Result<Self, Error> {
        Self::load_from(HOSTS_FILE_NAME)
    }

    /// Loads all host configs from the specified path.
    pub fn load_from<P>(path: P) -> Result<Self, Error>
    where
        P: AsRef<Path>,
    {
        load(path).map(Self)
    }

    /// Gets a host config by the hostname.
    pub fn get(&self, hostname: &str) -> Option<&Host> {
        self.0.get(hostname)
    }

    /// Sets a host config and returns the current value.
    /// If no values present currently, returns `None` .
    pub fn set(&mut self, hostname: impl Into<String>, host: Host) -> Option<Host> {
        self.0.insert(hostname.into(), host)
    }

    /// Retrieves a token from the secure storage or insecure storage.
    /// User interaction may be required to unlock the keychain, depending on the OS.
    /// If any token found for the hostname, returns None.
    #[allow(deprecated)]
    pub fn retrieve_token(&self, hostname: &str) -> Result<Option<String>, Error> {
        Ok(self.retrieve_token_secure(hostname)?.or_else(|| {
            self.get(hostname)
                .and_then(|h| match h.oauth_token.is_empty() {
                    true => None,
                    _ => Some(h.oauth_token.to_owned()),
                })
        }))
    }

    /// Retrieves a token from the secure storage only.
    /// User interaction may be required to unlock the keychain, depending on the OS.
    /// If any token found for the hostname, returns None.
    pub fn retrieve_token_secure(&self, hostname: &str) -> Result<Option<String>, Error> {
        Ok(Keyring
            .get(hostname)?
            .map(|t| String::from_utf8(t).unwrap()))
    }
}

/// Finds the default config directory effected by the environment.
pub fn find_config_directory() -> Option<PathBuf> {
    let gh_config_dir = var(GH_CONFIG_DIR).unwrap_or_default();
    if !gh_config_dir.is_empty() {
        return Some(PathBuf::from(gh_config_dir));
    }

    let xdg_config_home = var(XDG_CONFIG_HOME).unwrap_or_default();
    if !xdg_config_home.is_empty() {
        return Some(PathBuf::from(xdg_config_home).join("gh"));
    }

    #[cfg(target_os = "windows")]
    {
        let app_data = var(APP_DATA).unwrap_or_default();
        if !app_data.is_empty() {
            return Some(PathBuf::from(app_data).join("GitHub CLI"));
        }
    }

    home_dir().map(|p| p.join(".config").join("gh"))
}

/// Loads a file in the config directory as `T` type.
pub fn load<T, P>(path: P) -> Result<T, Error>
where
    T: for<'de> Deserialize<'de>,
    P: AsRef<Path>,
{
    serde_yaml::from_slice(
        std::fs::read(
            find_config_directory()
                .ok_or(Error::ConfigNotFound)?
                .join(path),
        )
        .map_err(Error::Io)?
        .as_ref(),
    )
    .map_err(Error::Yaml)
}