use std::fs;
use colored::Colorize;
use nika::error::NikaError;
use super::config::find_nika_dir;
#[derive(Debug, Clone)]
struct DiagnosticCheck {
name: &'static str,
status: DiagnosticStatus,
message: String,
suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
enum DiagnosticStatus {
Pass,
Warn,
Fail,
}
impl DiagnosticCheck {
fn pass(name: &'static str, message: impl Into<String>) -> Self {
Self {
name,
status: DiagnosticStatus::Pass,
message: message.into(),
suggestion: None,
}
}
fn warn(name: &'static str, message: impl Into<String>, suggestion: impl Into<String>) -> Self {
Self {
name,
status: DiagnosticStatus::Warn,
message: message.into(),
suggestion: Some(suggestion.into()),
}
}
fn fail(name: &'static str, message: impl Into<String>, suggestion: impl Into<String>) -> Self {
Self {
name,
status: DiagnosticStatus::Fail,
message: message.into(),
suggestion: Some(suggestion.into()),
}
}
fn icon(&self) -> &'static str {
match self.status {
DiagnosticStatus::Pass => "✓",
DiagnosticStatus::Warn => "⚠",
DiagnosticStatus::Fail => "✗",
}
}
}
pub async fn handle_doctor_command(full: bool, format: &str, quiet: bool) -> Result<(), NikaError> {
let mut checks: Vec<DiagnosticCheck> = vec![];
checks.extend(check_nika_directory());
checks.push(check_config_file());
checks.extend(check_api_keys());
checks.extend(check_trace_directory());
checks.push(DiagnosticCheck::pass(
"Version",
format!("nika {}", env!("CARGO_PKG_VERSION")),
));
checks.push(check_rust_version());
checks.push(check_workflow_files());
checks.push(check_npx());
if full {
checks.push(check_mcp_connectivity().await);
}
if format == "json" {
output_doctor_json(&checks)?;
} else {
output_doctor_text(&checks, quiet);
}
let has_failures = checks.iter().any(|c| c.status == DiagnosticStatus::Fail);
if has_failures {
return Err(NikaError::ValidationError {
reason: "Some diagnostic checks failed".to_string(),
});
}
Ok(())
}
fn check_nika_directory() -> Vec<DiagnosticCheck> {
let mut checks = vec![];
let dir = match find_nika_dir() {
Ok(dir) if dir.exists() => {
checks.push(DiagnosticCheck::pass(
"Project",
format!(".nika directory found at {}", dir.display()),
));
dir
}
Ok(dir) => {
checks.push(DiagnosticCheck::warn(
"Project",
format!("No .nika directory at {}", dir.display()),
"Run 'nika init' to create project structure",
));
return checks;
}
Err(_) => {
checks.push(DiagnosticCheck::fail(
"Project",
"Cannot determine current directory",
"Check filesystem permissions",
));
return checks;
}
};
if !dir.join("config.toml").exists() {
checks.push(DiagnosticCheck::warn(
"Project",
"config.toml missing from .nika/",
"Run 'nika init' to regenerate project structure",
));
}
if !dir.join("workflows").exists() {
checks.push(DiagnosticCheck::warn(
"Project",
"workflows/ directory missing from .nika/",
"Run 'nika init' to regenerate project structure",
));
}
checks
}
fn check_config_file() -> DiagnosticCheck {
let nika_dir = match find_nika_dir() {
Ok(d) => d,
Err(_) => {
return DiagnosticCheck::warn(
"Config",
"Cannot locate .nika directory",
"Run 'nika init' first",
)
}
};
let config_path = nika_dir.join("config.toml");
if !config_path.exists() {
return DiagnosticCheck::warn(
"Config",
"No config.toml found",
"Run 'nika init' to create default config",
);
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<toml::Value>(&content) {
Ok(_) => DiagnosticCheck::pass("Config", "config.toml is valid TOML"),
Err(e) => DiagnosticCheck::fail(
"Config",
format!("config.toml has syntax errors: {}", e),
"Run 'nika config edit' to fix",
),
},
Err(e) => DiagnosticCheck::fail(
"Config",
format!("Cannot read config.toml: {}", e),
"Check file permissions",
),
}
}
fn check_api_keys() -> Vec<DiagnosticCheck> {
let mut checks = vec![];
let keys = [
("ANTHROPIC_API_KEY", "Claude"),
("OPENAI_API_KEY", "OpenAI"),
("MISTRAL_API_KEY", "Mistral"),
("GROQ_API_KEY", "Groq"),
("DEEPSEEK_API_KEY", "DeepSeek"),
("GEMINI_API_KEY", "Gemini"),
("XAI_API_KEY", "xAI/Grok"),
];
let mut any_found = false;
for (env_var, provider) in keys {
if let Ok(val) = std::env::var(env_var) {
let len = val.len();
let is_valid = if env_var == "ANTHROPIC_API_KEY" {
val.starts_with("sk-ant-") && len > 40
} else if env_var == "OPENAI_API_KEY" {
val.starts_with("sk-") && len > 20
} else {
len > 10
};
if val.is_empty() {
checks.push(DiagnosticCheck::warn(
"API Key",
format!("{} key is empty ({})", provider, env_var),
format!("Set a valid {} key", provider),
));
} else if !is_valid {
checks.push(DiagnosticCheck::warn(
"API Key",
format!(
"{} key format looks invalid ({}, {} chars)",
provider, env_var, len
),
format!("Verify your {} API key is correct", provider),
));
any_found = true;
} else {
checks.push(DiagnosticCheck::pass(
"API Key",
format!("{} configured ({}, {} chars)", provider, env_var, len),
));
any_found = true;
}
}
}
if !any_found {
checks.push(DiagnosticCheck::warn(
"API Key",
"No LLM API keys found",
"Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or use provider: native",
));
}
checks
}
fn check_trace_directory() -> Vec<DiagnosticCheck> {
let mut checks = vec![];
let nika_dir = match find_nika_dir() {
Ok(d) => d,
Err(_) => {
checks.push(DiagnosticCheck::warn(
"Traces",
"Cannot locate .nika directory",
"Run 'nika init' first",
));
return checks;
}
};
let trace_dir = nika_dir.join("traces");
if !trace_dir.exists() {
checks.push(DiagnosticCheck::warn(
"Traces",
"Trace directory doesn't exist",
"It will be created on first workflow run",
));
return checks;
}
let test_file = trace_dir.join(".nika_doctor_test");
match fs::write(&test_file, b"test") {
Ok(_) => {
let _ = fs::remove_file(&test_file);
checks.push(DiagnosticCheck::pass(
"Traces",
format!("Trace directory writable ({})", trace_dir.display()),
));
}
Err(e) => {
checks.push(DiagnosticCheck::fail(
"Traces",
format!("Trace directory not writable: {}", e),
"Check directory permissions",
));
return checks;
}
}
if let Ok(entries) = fs::read_dir(&trace_dir) {
let count = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "ndjson")
.unwrap_or(false)
})
.count();
if count > 10_000 {
checks.push(DiagnosticCheck::warn(
"Traces",
format!(
"{} trace files accumulated ({:.1} MB estimated)",
count,
count as f64 * 0.005 ),
"Run 'nika trace clean --keep 100' to prune old traces",
));
} else if count > 1_000 {
checks.push(DiagnosticCheck::warn(
"Traces",
format!("{} trace files", count),
"Consider running 'nika trace clean --keep 100'",
));
} else {
checks.push(DiagnosticCheck::pass(
"Traces",
format!("{} trace files", count),
));
}
}
checks
}
fn check_workflow_files() -> DiagnosticCheck {
let count = fs::read_dir(".")
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.ends_with(".nika.yaml"))
.unwrap_or(false)
})
.count()
})
.unwrap_or(0);
let sub_count: usize = ["workflows", "examples", ".nika/workflows"]
.iter()
.filter_map(|dir| fs::read_dir(dir).ok())
.flat_map(|entries| entries.filter_map(|e| e.ok()))
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.ends_with(".nika.yaml"))
.unwrap_or(false)
})
.count();
let total = count + sub_count;
if total == 0 {
DiagnosticCheck::warn(
"Workflows",
"No .nika.yaml workflow files found",
"Run 'nika init' or 'nika new my-workflow --template simple-infer'",
)
} else {
DiagnosticCheck::pass("Workflows", format!("{} workflow files found", total))
}
}
fn check_rust_version() -> DiagnosticCheck {
const MSRV_MAJOR: u32 = 1;
const MSRV_MINOR: u32 = 86;
match std::process::Command::new("rustc")
.arg("--version")
.output()
{
Ok(output) => {
let version_str = String::from_utf8_lossy(&output.stdout);
let version_str = version_str.trim();
let parts: Vec<&str> = version_str
.strip_prefix("rustc ")
.unwrap_or(version_str)
.split(|c: char| !c.is_ascii_digit())
.collect();
if parts.len() >= 2 {
if let (Ok(major), Ok(minor)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
if major > MSRV_MAJOR || (major == MSRV_MAJOR && minor >= MSRV_MINOR) {
return DiagnosticCheck::pass("Rust", version_str.to_string());
} else {
return DiagnosticCheck::warn(
"Rust",
format!("{} (MSRV is {}.{})", version_str, MSRV_MAJOR, MSRV_MINOR),
"Update with: rustup update",
);
}
}
}
DiagnosticCheck::pass("Rust", version_str.to_string())
}
Err(_) => DiagnosticCheck::warn(
"Rust",
"rustc not found in PATH",
"Install Rust: https://rustup.rs",
),
}
}
fn check_npx() -> DiagnosticCheck {
match std::process::Command::new("npx").arg("--version").output() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
DiagnosticCheck::pass("npx", format!("npx {} available", version.trim()))
}
_ => DiagnosticCheck::warn(
"npx",
"npx not found",
"MCP servers using npx won't work. Install Node.js: https://nodejs.org",
),
}
}
async fn check_mcp_connectivity() -> DiagnosticCheck {
DiagnosticCheck::pass(
"MCP",
"MCP connectivity check (requires configured servers)",
)
}
fn output_doctor_text(checks: &[DiagnosticCheck], quiet: bool) {
if !quiet {
nika::display::print_doctor_header(env!("CARGO_PKG_VERSION"));
}
let mut pass_count = 0;
let mut warn_count = 0;
let mut fail_count = 0;
for check in checks {
let icon = match check.status {
DiagnosticStatus::Pass => check.icon().green(),
DiagnosticStatus::Warn => check.icon().yellow(),
DiagnosticStatus::Fail => check.icon().red(),
};
println!(" {} {} {}", icon, check.name.bold(), check.message);
if let Some(ref suggestion) = check.suggestion {
println!(" {} {}", "→".cyan(), suggestion.dimmed());
}
match check.status {
DiagnosticStatus::Pass => pass_count += 1,
DiagnosticStatus::Warn => warn_count += 1,
DiagnosticStatus::Fail => fail_count += 1,
}
}
if !quiet {
nika::display::print_doctor_summary(pass_count, warn_count, fail_count);
}
}
fn output_doctor_json(checks: &[DiagnosticCheck]) -> Result<(), NikaError> {
let results: Vec<serde_json::Value> = checks
.iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"status": match c.status {
DiagnosticStatus::Pass => "pass",
DiagnosticStatus::Warn => "warn",
DiagnosticStatus::Fail => "fail",
},
"message": c.message,
"suggestion": c.suggestion,
})
})
.collect();
let output = serde_json::json!({
"checks": results,
"summary": {
"pass": checks.iter().filter(|c| c.status == DiagnosticStatus::Pass).count(),
"warn": checks.iter().filter(|c| c.status == DiagnosticStatus::Warn).count(),
"fail": checks.iter().filter(|c| c.status == DiagnosticStatus::Fail).count(),
}
});
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}