use std::sync::OnceLock;
const DOTCOM: &str = "github.com";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Host(&'static str);
impl Default for Host {
fn default() -> Self {
Self::GITHUB_COM
}
}
impl std::fmt::Display for Host {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl Host {
pub const GITHUB_COM: Host = Host(DOTCOM);
pub fn parse(s: &str) -> Result<Host, String> {
let s = s.trim();
let rest = s
.strip_prefix("https://")
.or_else(|| s.strip_prefix("http://"))
.unwrap_or(s);
let name = rest.trim_end_matches('/').to_ascii_lowercase();
let valid = !name.is_empty()
&& !name.starts_with(['.', '-', ':'])
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':'));
if !valid {
return Err(format!("`{s}` is not a GitHub hostname"));
}
Ok(match name.as_str() {
DOTCOM | "www.github.com" | "api.github.com" => Self::GITHUB_COM,
_ => Host(Box::leak(name.into_boxed_str())),
})
}
pub fn resolve(
env: impl Fn(&str) -> Option<String>,
config: Option<&str>,
) -> Result<Host, String> {
let set = |v: Option<String>| v.filter(|v| !v.trim().is_empty());
let named = set(env("GHTUI_HOST"))
.or_else(|| set(config.map(str::to_owned)))
.or_else(|| set(env("GH_HOST")));
named.map_or(Ok(Self::GITHUB_COM), |name| Self::parse(&name))
}
pub fn name(self) -> &'static str {
self.0
}
pub fn is_enterprise(self) -> bool {
self.0 != DOTCOM
}
pub fn web(self) -> String {
format!("https://{}", self.0)
}
pub fn rest_api(self) -> String {
if self.is_enterprise() {
format!("https://{}/api/v3", self.0)
} else {
format!("https://api.{DOTCOM}")
}
}
pub fn graphql_api(self) -> String {
if self.is_enterprise() {
format!("https://{}/api/graphql", self.0)
} else {
format!("https://api.{DOTCOM}/graphql")
}
}
pub fn gists(self) -> String {
if self.is_enterprise() {
format!("https://{}/gist", self.0)
} else {
format!("https://gist.{DOTCOM}")
}
}
pub fn raw(self) -> String {
if self.is_enterprise() {
format!("https://{}/raw", self.0)
} else {
"https://raw.githubusercontent.com".to_owned()
}
}
pub fn git_url(self, owner: &str, name: &str) -> String {
format!("{}/{owner}/{name}.git", self.web())
}
pub fn serves(self, host: &str) -> bool {
host.eq_ignore_ascii_case(self.0)
|| (!self.is_enterprise() && host.eq_ignore_ascii_case("www.github.com"))
}
pub fn serves_url(self, url: &url::Url) -> bool {
url_host(url).is_some_and(|h| self.serves(&h))
}
pub fn serves_hostname(self, name: &str) -> bool {
let ours = self.0.split(':').next().unwrap_or(self.0);
name.eq_ignore_ascii_case(ours) || self.serves(name)
}
pub fn serves_gists(self, host: &str) -> bool {
host.len() > 5
&& host
.get(..5)
.is_some_and(|g| g.eq_ignore_ascii_case("gist."))
&& host
.get(5..)
.is_some_and(|rest| rest.eq_ignore_ascii_case(self.0))
}
}
pub fn url_host(url: &url::Url) -> Option<String> {
let name = url.host_str()?;
Some(match url.port() {
Some(port) => format!("{name}:{port}"),
None => name.to_owned(),
})
}
static CURRENT: OnceLock<Host> = OnceLock::new();
pub fn set(host: Host) {
if CURRENT.set(host).is_err() {
tracing::warn!(%host, "the GitHub host is already set");
}
}
pub fn current() -> Host {
#[cfg(test)]
if let Some(host) = TEST_HOST.with(std::cell::Cell::get) {
return host;
}
CURRENT.get().copied().unwrap_or_default()
}
#[cfg(test)]
thread_local! {
static TEST_HOST: std::cell::Cell<Option<Host>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
pub fn set_for_test(host: &str) -> impl Drop {
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
TEST_HOST.with(|h| h.set(None));
}
}
TEST_HOST.with(|h| h.set(Some(Host::parse(host).unwrap())));
Guard
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enterprise_bases() {
let ghe = Host::parse("https://GHE.example.com/").unwrap();
assert_eq!(ghe.name(), "ghe.example.com");
assert!(ghe.is_enterprise());
assert_eq!(ghe.web(), "https://ghe.example.com");
assert_eq!(ghe.rest_api(), "https://ghe.example.com/api/v3");
assert_eq!(ghe.graphql_api(), "https://ghe.example.com/api/graphql");
assert_eq!(ghe.gists(), "https://ghe.example.com/gist");
assert!(ghe.serves("ghe.example.com") && !ghe.serves("github.com"));
assert!(ghe.serves_gists("gist.ghe.example.com"));
let dotcom = Host::default();
assert_eq!(dotcom.rest_api(), "https://api.github.com");
assert_eq!(dotcom.graphql_api(), "https://api.github.com/graphql");
assert!(dotcom.serves("www.github.com") && !dotcom.serves("ghe.example.com"));
assert_eq!(Host::parse("api.github.com"), Ok(dotcom));
Host::parse("ghe.example.com/path").unwrap_err();
Host::parse("").unwrap_err();
}
#[test]
fn resolves_env_then_config_then_gh_host() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |k: &str| {
pairs
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.to_string())
}
};
let gh_host = env(&[("GH_HOST", "gh.example.com")]);
assert_eq!(
Host::resolve(gh_host, None).unwrap().name(),
"gh.example.com"
);
assert_eq!(
Host::resolve(gh_host, Some("conf.example.com"))
.unwrap()
.name(),
"conf.example.com"
);
let both = env(&[
("GHTUI_HOST", "mine.example.com"),
("GH_HOST", "gh.example.com"),
]);
assert_eq!(
Host::resolve(both, Some("conf.example.com"))
.unwrap()
.name(),
"mine.example.com"
);
assert_eq!(Host::resolve(env(&[]), None), Ok(Host::GITHUB_COM));
}
}