use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Output;
use serde::de::DeserializeOwned;
pub const GH_BIN: &str = "gh";
pub const GH_MISSING_HINT: &str =
"The GitHub CLI must be installed and authenticated (`gh auth login`) for this to work.";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GhError {
#[error("`gh` is not installed or not on PATH. {GH_MISSING_HINT}")]
NotInstalled,
#[error("failed to spawn `gh {args}`: {source}")]
Spawn {
args: String,
#[source]
source: std::io::Error,
},
#[error("`gh {args}` failed ({status}): {stderr}")]
NonZero {
args: String,
status: String,
stderr: String,
},
#[error("`gh {args}` returned empty output. {GH_MISSING_HINT}")]
Empty {
args: String,
},
#[error("failed to parse `gh {args}` JSON output: {source}")]
Json {
args: String,
#[source]
source: serde_json::Error,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GhOutput {
pub args: String,
pub code: Option<i32>,
pub success: bool,
pub stdout: String,
pub stderr: String,
}
impl GhOutput {
pub fn from_parts(
args: impl Into<String>,
code: Option<i32>,
stdout: impl Into<String>,
stderr: impl Into<String>,
) -> Self {
Self {
args: args.into(),
code,
success: code == Some(0),
stdout: stdout.into(),
stderr: stderr.into(),
}
}
fn from_output(args: String, out: Output) -> Self {
Self {
args,
code: out.status.code(),
success: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
pub fn ok(self) -> Result<Self, GhError> {
if self.success {
return Ok(self);
}
Err(GhError::NonZero {
args: self.args,
status: self
.code
.map_or_else(|| "signalled".to_string(), |c| format!("exit {c}")),
stderr: self.stderr.trim().to_string(),
})
}
pub fn stdout_trimmed(&self) -> &str {
self.stdout.trim()
}
pub fn combined(&self) -> String {
format!("{}\n{}", self.stdout, self.stderr)
}
}
#[derive(Debug, Clone, Default)]
pub struct GhCommand {
args: Vec<OsString>,
cwd: Option<PathBuf>,
envs: Vec<(OsString, Option<OsString>)>,
}
impl GhCommand {
pub fn bare() -> Self {
Self::default()
}
pub fn new<I, S>(args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
Self {
args: args
.into_iter()
.map(|a| a.as_ref().to_os_string())
.collect(),
..Self::default()
}
}
#[must_use]
pub fn repo(mut self, repo: Option<&str>) -> Self {
if let Some(repo) = repo.filter(|r| !r.trim().is_empty()) {
let mut prefixed: Vec<OsString> = vec![OsString::from("--repo"), OsString::from(repo)];
prefixed.append(&mut self.args);
self.args = prefixed;
}
self
}
#[must_use]
pub fn cwd(mut self, dir: impl AsRef<Path>) -> Self {
self.cwd = Some(dir.as_ref().to_path_buf());
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs.push((
key.as_ref().to_os_string(),
Some(value.as_ref().to_os_string()),
));
self
}
#[must_use]
pub fn env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
self.envs.push((key.as_ref().to_os_string(), None));
self
}
pub fn argv_display(&self) -> String {
self.args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ")
}
fn configure<C: CommandLike>(&self, mut cmd: C) -> C {
cmd.apply_args(&self.args);
if let Some(dir) = &self.cwd {
cmd.apply_cwd(dir);
}
for (k, v) in &self.envs {
match v {
Some(v) => cmd.apply_env(k, v),
None => cmd.apply_env_remove(k),
}
}
cmd
}
fn classify(&self, err: std::io::Error) -> GhError {
if err.kind() == std::io::ErrorKind::NotFound {
GhError::NotInstalled
} else {
GhError::Spawn {
args: self.argv_display(),
source: err,
}
}
}
pub fn to_std_command(&self) -> std::process::Command {
self.configure(std::process::Command::new(GH_BIN))
}
pub fn output_blocking(&self) -> Result<GhOutput, GhError> {
let out = self
.configure(std::process::Command::new(GH_BIN))
.output()
.map_err(|e| self.classify(e))?;
Ok(GhOutput::from_output(self.argv_display(), out))
}
pub async fn output(&self) -> Result<GhOutput, GhError> {
let out = self
.configure(tokio::process::Command::new(GH_BIN))
.output()
.await
.map_err(|e| self.classify(e))?;
Ok(GhOutput::from_output(self.argv_display(), out))
}
pub async fn stdout(&self) -> Result<String, GhError> {
Ok(self.output().await?.ok()?.stdout)
}
pub async fn nonempty_stdout(&self) -> Result<String, GhError> {
require_nonempty(self.output().await?.ok()?)
}
pub fn nonempty_stdout_blocking(&self) -> Result<String, GhError> {
require_nonempty(self.output_blocking()?.ok()?)
}
pub async fn json<T: DeserializeOwned>(&self) -> Result<T, GhError> {
let out = self.output().await?.ok()?;
serde_json::from_str(&out.stdout).map_err(|source| GhError::Json {
args: out.args,
source,
})
}
}
fn require_nonempty(out: GhOutput) -> Result<String, GhError> {
let trimmed = out.stdout_trimmed().to_string();
if trimmed.is_empty() {
return Err(GhError::Empty { args: out.args });
}
Ok(trimmed)
}
pub async fn gh_available() -> bool {
matches!(GhCommand::new(["auth", "status"]).output().await, Ok(o) if o.success)
}
trait CommandLike {
fn apply_args(&mut self, args: &[OsString]);
fn apply_cwd(&mut self, dir: &Path);
fn apply_env(&mut self, k: &OsStr, v: &OsStr);
fn apply_env_remove(&mut self, k: &OsStr);
}
impl CommandLike for std::process::Command {
fn apply_args(&mut self, args: &[OsString]) {
self.args(args);
}
fn apply_cwd(&mut self, dir: &Path) {
self.current_dir(dir);
}
fn apply_env(&mut self, k: &OsStr, v: &OsStr) {
self.env(k, v);
}
fn apply_env_remove(&mut self, k: &OsStr) {
self.env_remove(k);
}
}
impl CommandLike for tokio::process::Command {
fn apply_args(&mut self, args: &[OsString]) {
self.args(args);
}
fn apply_cwd(&mut self, dir: &Path) {
self.current_dir(dir);
}
fn apply_env(&mut self, k: &OsStr, v: &OsStr) {
self.env(k, v);
}
fn apply_env_remove(&mut self, k: &OsStr) {
self.env_remove(k);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn out(code: i32, stdout: &str, stderr: &str) -> GhOutput {
GhOutput::from_parts("auth token", Some(code), stdout, stderr)
}
#[test]
fn from_parts_derives_success_from_the_exit_code() {
assert!(GhOutput::from_parts("x", Some(0), "", "").success);
assert!(!GhOutput::from_parts("x", Some(1), "", "").success);
assert!(!GhOutput::from_parts("x", None, "", "").success);
}
#[test]
fn argv_renders_without_the_binary_name() {
let cmd = GhCommand::new(["issue", "list", "--limit", "5"]);
assert_eq!(cmd.argv_display(), "issue list --limit 5");
}
#[test]
fn bare_carries_no_argv() {
assert_eq!(GhCommand::bare().argv_display(), "");
}
#[test]
fn repo_is_prepended_once() {
let cmd = GhCommand::new(["issue", "list"]).repo(Some("o/r"));
assert_eq!(cmd.argv_display(), "--repo o/r issue list");
}
#[test]
fn repo_none_or_blank_leaves_argv_untouched() {
assert_eq!(
GhCommand::new(["issue", "list"]).repo(None).argv_display(),
"issue list"
);
assert_eq!(
GhCommand::new(["issue", "list"])
.repo(Some(" "))
.argv_display(),
"issue list"
);
}
#[test]
fn output_ok_maps_nonzero_to_error() {
let err = out(1, "", " not logged in ").ok().unwrap_err();
let GhError::NonZero { status, stderr, .. } = &err else {
panic!("expected NonZero, got {err:?}");
};
assert_eq!(status, "exit 1");
assert_eq!(stderr, "not logged in");
}
#[test]
fn nonzero_exit_carries_stderr() {
assert!(
out(2, "", "boom")
.ok()
.unwrap_err()
.to_string()
.contains("boom")
);
}
#[test]
fn zero_exit_passes_through_ok() {
assert_eq!(out(0, "tok\n", "").ok().unwrap().stdout_trimmed(), "tok");
}
#[test]
fn empty_stdout_is_rejected() {
let err = require_nonempty(out(0, " \n ", "")).unwrap_err();
assert!(matches!(err, GhError::Empty { .. }), "got {err:?}");
}
#[test]
fn nonempty_stdout_trims_a_real_value() {
assert_eq!(
require_nonempty(out(0, " a-token \n", "")).unwrap(),
"a-token"
);
}
#[test]
fn combined_joins_streams() {
assert_eq!(out(0, "a", "b").combined(), "a\nb");
}
#[test]
fn malformed_json_is_reported_as_json_error() {
let e = serde_json::from_str::<serde_json::Value>("{oops").unwrap_err();
let err = GhError::Json {
args: "issue list --json number".to_string(),
source: e,
};
assert!(err.to_string().contains("JSON"), "{err}");
}
#[test]
fn missing_binary_is_classified_not_installed() {
let cmd = GhCommand::new(["auth", "status"]);
let err = cmd.classify(std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(matches!(err, GhError::NotInstalled), "got {err:?}");
assert!(err.to_string().contains("gh auth login"));
}
#[test]
fn other_spawn_failures_stay_distinct_from_not_installed() {
let cmd = GhCommand::new(["auth", "status"]);
let err = cmd.classify(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
assert!(matches!(err, GhError::Spawn { .. }), "got {err:?}");
assert!(err.to_string().contains("auth status"));
}
#[tokio::test]
async fn gh_available_is_a_pure_probe() {
let _ = gh_available().await;
}
#[test]
fn cwd_and_env_survive_onto_the_builder() {
let cmd = GhCommand::new(["repo", "view"])
.cwd("/tmp")
.env("GH_CONFIG_DIR", "/tmp/ghcfg");
assert_eq!(cmd.cwd.as_deref(), Some(Path::new("/tmp")));
assert_eq!(cmd.envs.len(), 1);
assert_eq!(cmd.envs[0].0, OsString::from("GH_CONFIG_DIR"));
assert_eq!(cmd.envs[0].1, Some(OsString::from("/tmp/ghcfg")));
}
#[test]
fn to_std_command_carries_argv_and_env() {
let cmd = GhCommand::new(["pr", "list"])
.cwd("/tmp")
.env("GH_PAGER", "")
.env_remove("GH_REPO")
.to_std_command();
assert_eq!(cmd.get_program(), OsStr::new("gh"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, vec![OsStr::new("pr"), OsStr::new("list")]);
assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp")));
let envs: Vec<_> = cmd.get_envs().collect();
assert!(envs.contains(&(OsStr::new("GH_PAGER"), Some(OsStr::new("")))));
assert!(envs.contains(&(OsStr::new("GH_REPO"), None)));
}
#[test]
fn env_removals_are_recorded() {
let cmd = GhCommand::new(["pr", "list"]).env_remove("GH_REPO");
assert_eq!(cmd.envs, vec![(OsString::from("GH_REPO"), None)]);
}
#[test]
fn later_env_call_wins_over_an_earlier_removal() {
let cmd = GhCommand::new(["pr", "list"])
.env_remove("X")
.env("X", "v")
.to_std_command();
let envs: Vec<_> = cmd.get_envs().collect();
assert_eq!(
envs,
vec![(OsStr::new("X"), Some(OsStr::new("v")))],
"the later .env must win, leaving X set"
);
}
#[test]
fn later_removal_wins_over_an_earlier_env_call() {
let cmd = GhCommand::new(["pr", "list"])
.env("X", "v")
.env_remove("X")
.to_std_command();
let envs: Vec<_> = cmd.get_envs().collect();
assert_eq!(
envs,
vec![(OsStr::new("X"), None)],
"the later .env_remove must win, leaving X removed"
);
}
}