use std::fmt;
use std::path::Path;
use std::time::Duration;
use crate::types::RunnerError;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tracing::{debug, warn};
use crate::config::CliRunnerType;
use crate::process::{run_cli_command, CliOutput};
const AUTH_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const AUTH_CHECK_MAX_OUTPUT: usize = 64 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProviderReadiness {
Ready,
InstalledAuthUnverified {
reason: String,
},
NotReady {
reason: String,
action: String,
},
BinaryMissing {
expected_binary: String,
},
Unknown {
reason: String,
},
}
impl ProviderReadiness {
#[must_use]
pub const fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
#[must_use]
pub const fn is_installed(&self) -> bool {
matches!(self, Self::Ready | Self::InstalledAuthUnverified { .. })
}
}
impl fmt::Display for ProviderReadiness {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ready => write!(f, "ready"),
Self::InstalledAuthUnverified { reason } => {
write!(f, "installed (auth unverified: {reason})")
}
Self::NotReady { reason, action } => {
write!(f, "not ready: {reason} (action: {action})")
}
Self::BinaryMissing { expected_binary } => {
write!(f, "binary missing: {expected_binary}")
}
Self::Unknown { reason } => write!(f, "unknown: {reason}"),
}
}
}
pub async fn check_readiness(
runner_type: &CliRunnerType,
binary_path: &Path,
) -> Result<ProviderReadiness, RunnerError> {
if !binary_path.exists() {
return Ok(ProviderReadiness::BinaryMissing {
expected_binary: binary_path.display().to_string(),
});
}
match runner_type {
CliRunnerType::ClaudeCode => check_claude_readiness(binary_path).await,
CliRunnerType::Copilot => check_copilot_readiness(binary_path).await,
CliRunnerType::OpenCode => check_opencode_readiness(binary_path).await,
CliRunnerType::GeminiCli => check_gemini_readiness(binary_path).await,
CliRunnerType::CodexCli => check_codex_readiness(binary_path).await,
CliRunnerType::CursorAgent => {
check_version_probe_unverified(binary_path, "cursor-agent").await
}
CliRunnerType::GooseCli => check_version_probe_unverified(binary_path, "goose").await,
CliRunnerType::ClineCli => check_version_probe_unverified(binary_path, "cline").await,
CliRunnerType::ContinueCli => check_version_probe_unverified(binary_path, "cn").await,
CliRunnerType::WarpCli => check_version_probe_unverified(binary_path, "oz").await,
CliRunnerType::KiroCli => check_version_probe_unverified(binary_path, "kiro-cli").await,
CliRunnerType::KiloCli => check_version_probe_unverified(binary_path, "kilo").await,
#[cfg(feature = "copilot-headless")]
CliRunnerType::CopilotHeadless => check_copilot_readiness(binary_path).await,
}
}
#[must_use]
pub fn check_env_var_auth<'a>(var_names: &'a [&'a str]) -> Option<&'a str> {
var_names
.iter()
.find(|name| std::env::var(name).is_ok_and(|v| !v.is_empty()))
.copied()
}
async fn check_claude_readiness(binary_path: &Path) -> Result<ProviderReadiness, RunnerError> {
let mut cmd = Command::new(binary_path);
cmd.args(["auth", "status"]);
let output = run_cli_command(&mut cmd, AUTH_CHECK_TIMEOUT, AUTH_CHECK_MAX_OUTPUT).await;
match output {
Ok(CliOutput { exit_code: 0, .. }) => {
debug!("Claude Code auth status: ready");
Ok(ProviderReadiness::Ready)
}
Ok(cli_output) => {
let stderr = String::from_utf8_lossy(&cli_output.stderr);
warn!(exit_code = cli_output.exit_code, %stderr, "Claude Code auth check failed");
Ok(ProviderReadiness::NotReady {
reason: format!("Auth check exited with code {}", cli_output.exit_code),
action: "Run `claude auth login` to authenticate".to_owned(),
})
}
Err(e) => Ok(ProviderReadiness::Unknown {
reason: format!("Failed to run auth check: {e}"),
}),
}
}
async fn check_copilot_readiness(binary_path: &Path) -> Result<ProviderReadiness, RunnerError> {
if let Some(gh_result) = check_gh_auth_status().await {
return Ok(gh_result);
}
debug!("gh CLI not available, falling back to copilot --version probe");
let mut cmd = Command::new(binary_path);
cmd.arg("--version");
let output = run_cli_command(&mut cmd, AUTH_CHECK_TIMEOUT, AUTH_CHECK_MAX_OUTPUT).await;
match output {
Ok(CliOutput { exit_code: 0, .. }) => {
debug!("Copilot CLI version probe succeeded (auth not verified)");
Ok(ProviderReadiness::Ready)
}
Ok(cli_output) => {
let stderr = String::from_utf8_lossy(&cli_output.stderr);
warn!(exit_code = cli_output.exit_code, %stderr, "Copilot CLI version probe failed");
Ok(ProviderReadiness::NotReady {
reason: format!(
"copilot --version exited with code {}",
cli_output.exit_code
),
action: "Run `copilot` to complete GitHub authentication".to_owned(),
})
}
Err(e) => Ok(ProviderReadiness::Unknown {
reason: format!("Failed to run copilot --version: {e}"),
}),
}
}
async fn check_gh_auth_status() -> Option<ProviderReadiness> {
let mut cmd = Command::new("gh");
cmd.args(["auth", "status"]);
let output = run_cli_command(&mut cmd, AUTH_CHECK_TIMEOUT, AUTH_CHECK_MAX_OUTPUT)
.await
.ok()?;
if output.exit_code == 0 {
debug!("gh auth status: authenticated");
Some(ProviderReadiness::Ready)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(%stderr, "gh auth status: not authenticated");
Some(ProviderReadiness::NotReady {
reason: "GitHub CLI reports not authenticated".to_owned(),
action: "Run `gh auth login` to authenticate with GitHub".to_owned(),
})
}
}
async fn check_gemini_readiness(binary_path: &Path) -> Result<ProviderReadiness, RunnerError> {
if let Some(var) = check_env_var_auth(&["GOOGLE_API_KEY", "GEMINI_API_KEY"]) {
debug!(env_var = var, "Gemini: API key found in environment");
return Ok(ProviderReadiness::Ready);
}
check_version_probe_unverified(binary_path, "gemini").await
}
async fn check_codex_readiness(binary_path: &Path) -> Result<ProviderReadiness, RunnerError> {
if check_env_var_auth(&["OPENAI_API_KEY"]).is_some() {
debug!("Codex: OPENAI_API_KEY found in environment");
return Ok(ProviderReadiness::Ready);
}
check_version_probe_unverified(binary_path, "codex").await
}
async fn check_opencode_readiness(binary_path: &Path) -> Result<ProviderReadiness, RunnerError> {
if let Some(gh_result) = check_gh_auth_status().await {
return Ok(gh_result);
}
check_version_probe_unverified(binary_path, "opencode").await
}
async fn check_version_probe_unverified(
binary_path: &Path,
name: &str,
) -> Result<ProviderReadiness, RunnerError> {
let mut cmd = Command::new(binary_path);
cmd.arg("--version");
let output = run_cli_command(&mut cmd, AUTH_CHECK_TIMEOUT, AUTH_CHECK_MAX_OUTPUT).await;
match output {
Ok(CliOutput { exit_code: 0, .. }) => {
debug!(runner = name, "Version probe succeeded (auth not verified)");
Ok(ProviderReadiness::InstalledAuthUnverified {
reason: format!("{name} has no dedicated auth probe"),
})
}
Ok(cli_output) => {
let stderr = String::from_utf8_lossy(&cli_output.stderr);
warn!(runner = name, exit_code = cli_output.exit_code, %stderr, "Version probe failed");
Ok(ProviderReadiness::NotReady {
reason: format!("{name} --version exited with code {}", cli_output.exit_code),
action: format!("Verify {name} is properly installed"),
})
}
Err(e) => Ok(ProviderReadiness::Unknown {
reason: format!("Failed to run {name} --version: {e}"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_readiness_is_ready() {
assert!(ProviderReadiness::Ready.is_ready());
}
#[test]
fn test_provider_readiness_not_ready() {
let status = ProviderReadiness::NotReady {
reason: "not authed".to_owned(),
action: "login".to_owned(),
};
assert!(!status.is_ready());
}
#[test]
fn test_provider_readiness_binary_missing() {
let status = ProviderReadiness::BinaryMissing {
expected_binary: "claude".to_owned(),
};
assert!(!status.is_ready());
}
#[test]
fn test_provider_readiness_unknown() {
let status = ProviderReadiness::Unknown {
reason: "timeout".to_owned(),
};
assert!(!status.is_ready());
}
#[test]
fn test_provider_readiness_installed_auth_unverified() {
let status = ProviderReadiness::InstalledAuthUnverified {
reason: "no auth probe".to_owned(),
};
assert!(!status.is_ready());
assert!(status.is_installed());
}
#[test]
fn test_is_installed_for_ready() {
assert!(ProviderReadiness::Ready.is_installed());
}
#[test]
fn test_is_installed_for_not_ready() {
let status = ProviderReadiness::NotReady {
reason: "x".to_owned(),
action: "y".to_owned(),
};
assert!(!status.is_installed());
}
#[test]
fn test_provider_readiness_display() {
assert_eq!(format!("{}", ProviderReadiness::Ready), "ready");
let unverified = ProviderReadiness::InstalledAuthUnverified {
reason: "no auth probe".to_owned(),
};
let display = format!("{unverified}");
assert!(display.contains("installed"));
assert!(display.contains("auth unverified"));
let not_ready = ProviderReadiness::NotReady {
reason: "expired token".to_owned(),
action: "re-login".to_owned(),
};
assert!(format!("{not_ready}").contains("expired token"));
assert!(format!("{not_ready}").contains("re-login"));
let missing = ProviderReadiness::BinaryMissing {
expected_binary: "claude".to_owned(),
};
assert!(format!("{missing}").contains("claude"));
let unknown = ProviderReadiness::Unknown {
reason: "error".to_owned(),
};
assert!(format!("{unknown}").contains("error"));
}
#[test]
fn test_check_env_var_auth_found() {
std::env::set_var("TEST_EMBACLE_AUTH_KEY", "secret123");
let result = check_env_var_auth(&["NONEXISTENT_VAR", "TEST_EMBACLE_AUTH_KEY"]);
assert_eq!(result, Some("TEST_EMBACLE_AUTH_KEY"));
std::env::remove_var("TEST_EMBACLE_AUTH_KEY");
}
#[test]
fn test_check_env_var_auth_not_found() {
let result = check_env_var_auth(&["EMBACLE_TEST_DEFINITELY_NOT_SET_12345"]);
assert!(result.is_none());
}
#[test]
fn test_check_env_var_auth_empty_value_skipped() {
std::env::set_var("TEST_EMBACLE_EMPTY_KEY", "");
let result = check_env_var_auth(&["TEST_EMBACLE_EMPTY_KEY"]);
assert!(result.is_none());
std::env::remove_var("TEST_EMBACLE_EMPTY_KEY");
}
}