use serde::Serialize;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize)]
pub(crate) struct GhCredentialReport {
pub(crate) gh_auth: GhAuthStatus,
pub(crate) ssh_keys: SshKeyStatus,
pub(crate) git_config: GitConfigStatus,
pub(crate) origin_remote: OriginRemoteStatus,
}
#[derive(Debug, Serialize)]
pub(crate) struct GhAuthStatus {
pub(crate) available: bool,
pub(crate) logged_in: bool,
pub(crate) error: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct SshKeyStatus {
pub(crate) found: Vec<String>,
pub(crate) any_present: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct GitConfigStatus {
pub(crate) user_name: Option<String>,
pub(crate) user_email: Option<String>,
pub(crate) complete: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct OriginRemoteStatus {
pub(crate) url: Option<String>,
pub(crate) present: bool,
pub(crate) error: Option<String>,
}
pub(crate) fn diagnose(app_dir: &Path) -> GhCredentialReport {
GhCredentialReport {
gh_auth: check_gh_auth(),
ssh_keys: check_ssh_keys(),
git_config: check_git_config(),
origin_remote: check_origin_remote(app_dir),
}
}
#[allow(dead_code)]
pub(crate) fn build_guidance(report: &GhCredentialReport) -> String {
let mut lines: Vec<String> = Vec::new();
if !report.gh_auth.logged_in {
if !report.gh_auth.available {
lines.push(
"- Install the GitHub CLI (https://cli.github.com) and run `gh auth login`."
.to_string(),
);
} else {
lines.push("- Run `gh auth login` to authenticate with GitHub.".to_string());
}
}
if !report.ssh_keys.any_present {
lines.push(
"- Generate an SSH key: `ssh-keygen -t ed25519` and add it via `gh ssh-key add`."
.to_string(),
);
}
if !report.git_config.complete {
lines.push(
"- Configure git identity: `git config --global user.name \"...\"` and `git config --global user.email \"...\"`."
.to_string(),
);
}
if !report.origin_remote.present {
lines.push(
"- Set the origin remote for your project: `git remote add origin <url>`.".to_string(),
);
}
if lines.is_empty() {
"All credential checks passed; the push failure may be caused by repository permissions or branch protection rules.".to_string()
} else {
format!("Detected setup gaps:\n{}", lines.join("\n"))
}
}
fn check_gh_auth() -> GhAuthStatus {
match Command::new("gh")
.args(["auth", "status"])
.env("LANG", "C")
.output()
{
Ok(out) if out.status.success() => GhAuthStatus {
available: true,
logged_in: true,
error: None,
},
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let msg = if !stderr.is_empty() { stderr } else { stdout };
GhAuthStatus {
available: true,
logged_in: false,
error: if msg.is_empty() { None } else { Some(msg) },
}
}
Err(e) => GhAuthStatus {
available: false,
logged_in: false,
error: Some(e.to_string()),
},
}
}
fn check_ssh_keys() -> SshKeyStatus {
let candidates = [
"id_ed25519",
"id_rsa",
"id_ecdsa",
"id_dsa",
"id_ed25519_sk",
"id_ecdsa_sk",
];
let home = dirs::home_dir();
let found: Vec<String> = match home {
None => Vec::new(),
Some(h) => {
let ssh_dir = h.join(".ssh");
candidates
.iter()
.filter(|name| ssh_dir.join(name).exists())
.map(|name| format!("~/.ssh/{name}"))
.collect()
}
};
let any_present = !found.is_empty();
SshKeyStatus { found, any_present }
}
fn check_git_config() -> GitConfigStatus {
let user_name = run_git_config("user.name");
let user_email = run_git_config("user.email");
let complete = user_name.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
&& user_email
.as_deref()
.map(|s| !s.is_empty())
.unwrap_or(false);
GitConfigStatus {
user_name,
user_email,
complete,
}
}
fn run_git_config(key: &str) -> Option<String> {
let out = match Command::new("git")
.args(["config", "--get", key])
.env("LANG", "C")
.output()
{
Ok(o) => o,
Err(e) => {
tracing::warn!(error = %e, key, "git config spawn failed");
return None;
}
};
if out.status.success() {
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
} else {
None
}
}
fn check_origin_remote(dir: &Path) -> OriginRemoteStatus {
match Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(dir)
.env("LANG", "C")
.output()
{
Ok(out) if out.status.success() => {
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
let present = !url.is_empty();
OriginRemoteStatus {
url: if url.is_empty() { None } else { Some(url) },
present,
error: None,
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
OriginRemoteStatus {
url: None,
present: false,
error: if stderr.is_empty() {
None
} else {
Some(stderr)
},
}
}
Err(e) => OriginRemoteStatus {
url: None,
present: false,
error: Some(e.to_string()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn diagnose_does_not_panic_with_nonexistent_dir() {
let dir = PathBuf::from("/nonexistent/path/that/does/not/exist");
let report = diagnose(&dir);
assert!(!report.origin_remote.present);
}
#[test]
fn diagnose_returns_valid_schema() {
let dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let report = diagnose(&dir);
assert_eq!(
report.ssh_keys.any_present,
!report.ssh_keys.found.is_empty()
);
if report.git_config.complete {
assert!(report.git_config.user_name.is_some());
assert!(report.git_config.user_email.is_some());
}
}
#[test]
fn build_guidance_returns_nonempty_string_for_failing_report() {
let report = GhCredentialReport {
gh_auth: GhAuthStatus {
available: false,
logged_in: false,
error: Some("binary not found".to_string()),
},
ssh_keys: SshKeyStatus {
found: Vec::new(),
any_present: false,
},
git_config: GitConfigStatus {
user_name: None,
user_email: None,
complete: false,
},
origin_remote: OriginRemoteStatus {
url: None,
present: false,
error: None,
},
};
let guidance = build_guidance(&report);
assert!(!guidance.is_empty());
assert!(guidance.contains("setup gaps") || guidance.contains("gh auth"));
}
#[test]
fn build_guidance_all_ok() {
let report = GhCredentialReport {
gh_auth: GhAuthStatus {
available: true,
logged_in: true,
error: None,
},
ssh_keys: SshKeyStatus {
found: vec!["~/.ssh/id_ed25519".to_string()],
any_present: true,
},
git_config: GitConfigStatus {
user_name: Some("Alice".to_string()),
user_email: Some("alice@example.com".to_string()),
complete: true,
},
origin_remote: OriginRemoteStatus {
url: Some("https://github.com/example/repo.git".to_string()),
present: true,
error: None,
},
};
let guidance = build_guidance(&report);
assert!(guidance.contains("All credential checks passed"));
}
}