use crate::application::cli::error::{CliError, CliResult};
use crate::application::cli::formatters::output::OutputFormatter;
use crate::application::cli::formatters::progress::Spinner;
use reqwest::Client;
use serde_json::json;
use std::collections::HashMap;
use std::env;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
enum CheckStatus {
Pass,
Warn(String),
Fail(String),
Skip(String),
}
#[derive(Debug, Clone, Copy)]
enum CheckCategory {
System,
Environment,
Provider,
Service,
}
impl CheckCategory {
fn label(&self) -> &str {
match self {
CheckCategory::System => "System",
CheckCategory::Environment => "Environment",
CheckCategory::Provider => "LLM Provider",
CheckCategory::Service => "Service",
}
}
}
struct CheckResult {
category: CheckCategory,
name: String,
status: CheckStatus,
}
impl CheckResult {
fn new(category: CheckCategory, name: impl Into<String>, status: CheckStatus) -> Self {
Self {
category,
name: name.into(),
status,
}
}
}
async fn validate_openai_key(api_key: &str) -> CliResult<()> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| CliError::execution(format!("Failed to create HTTP client: {}", e)))?;
let response = client
.get("https://api.openai.com/v1/models")
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await
.map_err(|e| CliError::execution(format!("API request failed: {}", e)))?;
if response.status().is_success() {
Ok(())
} else {
let status = response.status();
Err(CliError::execution(format!(
"OpenAI API validation failed ({})",
status
)))
}
}
async fn validate_anthropic_key(api_key: &str) -> CliResult<()> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| CliError::execution(format!("Failed to create HTTP client: {}", e)))?;
let body = json!({
"model": "claude-3-haiku-20240307",
"max_tokens": 1,
"messages": [{"role": "user", "content": "test"}]
});
let response = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| CliError::execution(format!("API request failed: {}", e)))?;
if response.status().is_success() {
Ok(())
} else {
let status = response.status();
Err(CliError::execution(format!(
"Anthropic API validation failed ({})",
status
)))
}
}
async fn validate_deepseek_key(api_key: &str) -> CliResult<()> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| CliError::execution(format!("Failed to create HTTP client: {}", e)))?;
let response = client
.get("https://api.deepseek.com/v1/models")
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await
.map_err(|e| CliError::execution(format!("API request failed: {}", e)))?;
if response.status().is_success() {
Ok(())
} else {
let status = response.status();
Err(CliError::execution(format!(
"DeepSeek API validation failed ({})",
status
)))
}
}
async fn check_redis_connectivity(redis_url: &str) -> CliResult<()> {
if redis_url.starts_with("redis://") || redis_url.starts_with("rediss://") {
Ok(())
} else {
Err(CliError::configuration(format!(
"Invalid Redis URL format: {}",
redis_url
)))
}
}
async fn check_qdrant_connectivity(qdrant_url: &str) -> CliResult<()> {
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| CliError::execution(format!("Failed to create HTTP client: {}", e)))?;
let url = format!("{}/collections", qdrant_url.trim_end_matches('/'));
let response = client
.get(&url)
.send()
.await
.map_err(|e| CliError::execution(format!("Qdrant connection failed: {}", e)))?;
if response.status().is_success() {
Ok(())
} else {
Err(CliError::execution(format!(
"Qdrant API check failed ({})",
response.status()
)))
}
}
fn check_cli_version() -> CheckResult {
let version = env!("CARGO_PKG_VERSION");
CheckResult::new(
CheckCategory::System,
format!("Paladin CLI v{}", version),
CheckStatus::Pass,
)
}
fn check_rust_version() -> CheckResult {
let output = Command::new("rustc")
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
match output {
Some(version) => CheckResult::new(
CheckCategory::System,
format!("Rust Toolchain ({})", version),
CheckStatus::Pass,
),
None => CheckResult::new(
CheckCategory::System,
"Rust Toolchain",
CheckStatus::Fail("Could not detect Rust toolchain".into()),
),
}
}
fn check_env_file() -> CheckResult {
let env_path = Path::new(".env");
if env_path.exists() {
CheckResult::new(CheckCategory::Environment, ".env file", CheckStatus::Pass)
} else {
CheckResult::new(
CheckCategory::Environment,
".env file",
CheckStatus::Warn("Not found - run 'paladin onboarding' to create".into()),
)
}
}
async fn check_openai() -> CheckResult {
match env::var("OPENAI_API_KEY") {
Ok(api_key) if !api_key.is_empty() => match validate_openai_key(&api_key).await {
Ok(_) => CheckResult::new(CheckCategory::Provider, "OpenAI", CheckStatus::Pass),
Err(e) => CheckResult::new(
CheckCategory::Provider,
"OpenAI",
CheckStatus::Fail(format!("API validation failed: {}", e)),
),
},
_ => CheckResult::new(
CheckCategory::Provider,
"OpenAI",
CheckStatus::Skip("OPENAI_API_KEY not configured".into()),
),
}
}
async fn check_anthropic() -> CheckResult {
match env::var("ANTHROPIC_API_KEY") {
Ok(api_key) if !api_key.is_empty() => match validate_anthropic_key(&api_key).await {
Ok(_) => CheckResult::new(CheckCategory::Provider, "Anthropic", CheckStatus::Pass),
Err(e) => CheckResult::new(
CheckCategory::Provider,
"Anthropic",
CheckStatus::Fail(format!("API validation failed: {}", e)),
),
},
_ => CheckResult::new(
CheckCategory::Provider,
"Anthropic",
CheckStatus::Skip("ANTHROPIC_API_KEY not configured".into()),
),
}
}
async fn check_deepseek() -> CheckResult {
match env::var("DEEPSEEK_API_KEY") {
Ok(api_key) if !api_key.is_empty() => match validate_deepseek_key(&api_key).await {
Ok(_) => CheckResult::new(CheckCategory::Provider, "DeepSeek", CheckStatus::Pass),
Err(e) => CheckResult::new(
CheckCategory::Provider,
"DeepSeek",
CheckStatus::Fail(format!("API validation failed: {}", e)),
),
},
_ => CheckResult::new(
CheckCategory::Provider,
"DeepSeek",
CheckStatus::Skip("DEEPSEEK_API_KEY not configured".into()),
),
}
}
async fn check_redis() -> CheckResult {
match env::var("REDIS_URL") {
Ok(redis_url) if !redis_url.is_empty() => {
match check_redis_connectivity(&redis_url).await {
Ok(_) => CheckResult::new(CheckCategory::Service, "Redis", CheckStatus::Pass),
Err(e) => CheckResult::new(
CheckCategory::Service,
"Redis",
CheckStatus::Fail(format!("Connection failed: {}", e)),
),
}
}
_ => CheckResult::new(
CheckCategory::Service,
"Redis",
CheckStatus::Skip("REDIS_URL not configured".into()),
),
}
}
async fn check_qdrant() -> CheckResult {
match env::var("QDRANT_URL") {
Ok(qdrant_url) if !qdrant_url.is_empty() => {
match check_qdrant_connectivity(&qdrant_url).await {
Ok(_) => CheckResult::new(CheckCategory::Service, "Qdrant", CheckStatus::Pass),
Err(e) => CheckResult::new(
CheckCategory::Service,
"Qdrant",
CheckStatus::Fail(format!("Connection failed: {}", e)),
),
}
}
_ => CheckResult::new(
CheckCategory::Service,
"Qdrant",
CheckStatus::Skip("QDRANT_URL not configured".into()),
),
}
}
pub async fn run_setup_check(verbose: bool) -> Result<i32, CliError> {
let formatter = OutputFormatter::new();
formatter.header("Paladin Setup Check");
println!();
let mut results = Vec::new();
results.push(check_cli_version());
results.push(check_rust_version());
results.push(check_env_file());
let spinner = Spinner::new("Checking LLM providers...");
results.push(check_openai().await);
results.push(check_anthropic().await);
results.push(check_deepseek().await);
spinner.finish_with_message("LLM provider checks complete");
println!();
let spinner = Spinner::new("Checking optional services...");
results.push(check_redis().await);
results.push(check_qdrant().await);
spinner.finish_with_message("Service checks complete");
println!();
let mut categorized: HashMap<String, Vec<&CheckResult>> = HashMap::new();
for result in &results {
categorized
.entry(result.category.label().to_string())
.or_default()
.push(result);
}
let mut has_failures = false;
let mut has_warnings = false;
for (category, checks) in &categorized {
println!("{}:", category);
for check in checks {
match &check.status {
CheckStatus::Pass => {
println!(" ✓ {} ", check.name);
if verbose {
formatter.info(" All checks passed");
}
}
CheckStatus::Warn(msg) => {
has_warnings = true;
println!(" ⚠ {} ", check.name);
formatter.warning(&format!(" {}", msg));
}
CheckStatus::Fail(msg) => {
has_failures = true;
println!(" ✗ {} ", check.name);
formatter.error(&format!(" {}", msg));
}
CheckStatus::Skip(msg) => {
if verbose {
println!(" ○ {} ", check.name);
formatter.info(&format!(" {}", msg));
}
}
}
}
println!();
}
let pass_count = results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Pass))
.count();
let warn_count = results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Warn(_)))
.count();
let fail_count = results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Fail(_)))
.count();
let skip_count = results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Skip(_)))
.count();
formatter.header("Summary");
println!(" Passed: {}", pass_count);
if warn_count > 0 {
println!(" Warnings: {}", warn_count);
}
if fail_count > 0 {
println!(" Failed: {}", fail_count);
}
if verbose && skip_count > 0 {
println!(" Skipped: {}", skip_count);
}
println!();
let exit_code = if has_failures {
formatter.error("Setup check failed - please address the errors above");
1
} else if has_warnings {
formatter.warning("Setup check completed with warnings");
2
} else {
formatter.success("All checks passed! ✨");
0
};
println!();
if exit_code > 0 {
formatter.box_message(&[
"Next Steps:",
"1. Run 'paladin onboarding' to configure missing providers",
"2. Verify environment variables in your .env file",
"3. Run 'paladin setup-check' again to verify",
]);
}
Ok(exit_code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_cli_version() {
let result = check_cli_version();
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn test_check_rust_version() {
let result = check_rust_version();
assert!(matches!(
result.status,
CheckStatus::Pass | CheckStatus::Fail(_)
));
}
#[test]
fn test_check_env_file() {
let result = check_env_file();
assert!(matches!(
result.status,
CheckStatus::Pass | CheckStatus::Warn(_)
));
}
#[tokio::test]
#[ignore] async fn test_validate_openai_key_invalid() {
let result = validate_openai_key("invalid-key-12345").await;
assert!(result.is_err());
}
#[tokio::test]
#[ignore] async fn test_validate_anthropic_key_invalid() {
let result = validate_anthropic_key("invalid-key-12345").await;
assert!(result.is_err());
}
#[tokio::test]
#[ignore] async fn test_validate_deepseek_key_invalid() {
let result = validate_deepseek_key("invalid-key-12345").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_check_openai_no_env() {
unsafe {
env::remove_var("OPENAI_API_KEY");
}
let result = check_openai().await;
assert!(matches!(result.status, CheckStatus::Skip(_)));
}
#[tokio::test]
async fn test_check_redis_invalid_url() {
let result = check_redis_connectivity("invalid-url").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_check_redis_valid_url_format() {
let result = check_redis_connectivity("redis://localhost:6379").await;
assert!(result.is_ok());
}
}