use crate::observability::Sanitizer;
use crate::tools::registry::get_tool;
use crate::tools::spec::{ToolSpec, get_tool_spec};
use serde::Serialize;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Serialize)]
pub struct DiagnosticReport {
pub tool: String,
pub installation: InstallationStatus,
pub binary: Option<BinaryAnalysis>,
pub dependencies: Vec<DependencyStatus>,
pub config_files: Vec<ConfigFile>,
pub health_checks: Vec<HealthCheck>,
pub issues: Vec<Issue>,
pub fixes: Vec<Fix>,
}
#[derive(Debug, Serialize)]
pub struct InstallationStatus {
pub installed: bool,
pub version: Option<String>,
pub location: Option<String>,
pub method: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct BinaryAnalysis {
pub file_type: String,
pub permissions: String,
pub owner: String,
pub symlink_target: Option<String>,
pub size: u64,
}
#[derive(Debug, Serialize)]
pub struct DependencyStatus {
pub name: String,
pub available: bool,
pub details: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ConfigFile {
pub path: String,
pub exists: bool,
pub size: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct HealthCheck {
pub name: String,
pub passed: bool,
pub details: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Issue {
pub severity: IssueSeverity,
pub description: String,
pub fix_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IssueSeverity {
Error,
Warning,
#[allow(dead_code)] Info,
}
#[derive(Debug, Serialize)]
pub struct Fix {
pub id: String,
pub description: String,
pub command: Option<String>,
pub auto_applicable: bool,
}
pub fn run_diagnose(tool: &str, fix: bool, export: bool, _scope: &str, output_format: &str) -> i32 {
let tool_spec = match get_tool_spec(tool) {
Some(spec) => spec,
None => {
if get_tool(tool).is_some() {
eprintln!("Tool '{}' is registered but has no diagnostic spec.", tool);
eprintln!("Only tools with full spec definitions can be diagnosed.");
} else {
eprintln!(
"Unknown tool: '{}'. Run 'jarvy tools' to see available tools.",
tool
);
}
return 1;
}
};
println!("Diagnosing: {}", tool);
println!("{}", "=".repeat(50));
println!();
let report = diagnose_tool(tool, tool_spec);
if output_format == "json" {
match serde_json::to_string_pretty(&report) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Failed to serialize report: {}", e),
}
} else {
print_diagnostic_report(&report);
}
if export {
let filename = format!("jarvy-diagnose-{}-{}.json", tool, chrono_timestamp());
let sanitizer = Sanitizer::new();
let json = serde_json::to_string_pretty(&report).unwrap_or_default();
let sanitized = sanitizer.sanitize(&json);
match std::fs::write(&filename, sanitized) {
Ok(_) => println!("\nDiagnostic export saved to: {}", filename),
Err(e) => eprintln!("\nFailed to export: {}", e),
}
}
if fix && !report.fixes.is_empty() {
println!("\nApplying fixes...");
for fix_item in &report.fixes {
if fix_item.auto_applicable {
if let Some(ref cmd) = fix_item.command {
println!(" Running: {}", cmd);
println!(" (Fix application not yet implemented)");
}
} else {
println!(" Manual fix required: {}", fix_item.description);
}
}
}
0
}
fn diagnose_tool(tool_name: &str, spec: &ToolSpec) -> DiagnosticReport {
let mut issues = Vec::new();
let mut fixes = Vec::new();
let installation = check_installation(tool_name, spec);
let binary = if installation.installed {
installation
.location
.as_ref()
.and_then(|loc| analyze_binary(loc).ok())
} else {
issues.push(Issue {
severity: IssueSeverity::Error,
description: format!("{} is not installed", tool_name),
fix_id: Some("install".to_string()),
});
fixes.push(Fix {
id: "install".to_string(),
description: format!("Install {} using Jarvy", tool_name),
command: Some(format!("jarvy setup --only {}", tool_name)),
auto_applicable: false,
});
None
};
let dependencies = check_dependencies(tool_name, spec);
let config_files = find_config_files(tool_name);
let health_checks = run_health_checks(tool_name, spec, &installation);
if installation.installed {
if let Some(ref loc) = installation.location {
let path_issues = check_path_issues(loc);
issues.extend(path_issues);
}
}
for check in &health_checks {
if !check.passed {
issues.push(Issue {
severity: IssueSeverity::Warning,
description: format!(
"Health check '{}' failed: {}",
check.name,
check.details.as_deref().unwrap_or("unknown")
),
fix_id: None,
});
}
}
DiagnosticReport {
tool: tool_name.to_string(),
installation,
binary,
dependencies,
config_files,
health_checks,
issues,
fixes,
}
}
fn check_installation(_tool_name: &str, spec: &ToolSpec) -> InstallationStatus {
let command = spec.command;
let which_output = Command::new("which").arg(command).output();
let location = which_output.ok().and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
});
let installed = location.is_some();
let version = if installed {
get_tool_version(command, "--version")
} else {
None
};
let method = if installed {
detect_install_method(location.as_deref())
} else {
None
};
InstallationStatus {
installed,
version,
location,
method,
}
}
fn get_tool_version(command: &str, version_arg: &str) -> Option<String> {
let output = Command::new(command).arg(version_arg).output().ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr);
let re = regex::Regex::new(r"(\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)").ok()?;
re.captures(&combined)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
} else {
None
}
}
fn detect_install_method(location: Option<&str>) -> Option<String> {
let loc = location?;
if loc.contains("/homebrew/")
|| loc.contains("/opt/homebrew/")
|| loc.contains("/usr/local/Cellar/")
{
Some("homebrew".to_string())
} else if loc.contains("/.cargo/") {
Some("cargo".to_string())
} else if loc.contains("/.nvm/") {
Some("nvm".to_string())
} else if loc.contains("/.pyenv/") {
Some("pyenv".to_string())
} else if loc.contains("/.rustup/") {
Some("rustup".to_string())
} else if loc.contains("/snap/") {
Some("snap".to_string())
} else if loc.starts_with("/usr/bin/") || loc.starts_with("/bin/") {
Some("system".to_string())
} else {
Some("manual".to_string())
}
}
#[cfg(unix)]
fn analyze_binary(path: &str) -> Result<BinaryAnalysis, std::io::Error> {
use std::os::unix::fs::MetadataExt;
let metadata = std::fs::metadata(path)?;
let symlink_meta = std::fs::symlink_metadata(path)?;
let file_type = Command::new("file")
.arg("-b")
.arg(path)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let mode = metadata.mode();
let permissions = format_permissions(mode);
let owner = format!("{}:{}", metadata.uid(), metadata.gid());
let symlink_target = if symlink_meta.file_type().is_symlink() {
std::fs::read_link(path)
.ok()
.map(|p| p.to_string_lossy().to_string())
} else {
None
};
Ok(BinaryAnalysis {
file_type,
permissions,
owner,
symlink_target,
size: metadata.len(),
})
}
#[cfg(not(unix))]
fn analyze_binary(_path: &str) -> Result<BinaryAnalysis, std::io::Error> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"binary analysis is unix-only",
))
}
fn format_permissions(mode: u32) -> String {
let user = (mode >> 6) & 0o7;
let group = (mode >> 3) & 0o7;
let other = mode & 0o7;
let format_triplet = |bits: u32| -> String {
format!(
"{}{}{}",
if bits & 4 != 0 { 'r' } else { '-' },
if bits & 2 != 0 { 'w' } else { '-' },
if bits & 1 != 0 { 'x' } else { '-' }
)
};
format!(
"-{}{}{}",
format_triplet(user),
format_triplet(group),
format_triplet(other)
)
}
fn check_dependencies(tool_name: &str, _spec: &ToolSpec) -> Vec<DependencyStatus> {
let mut deps = Vec::new();
match tool_name {
"docker" => {
let daemon_running = Command::new("docker")
.arg("info")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
deps.push(DependencyStatus {
name: "Docker daemon".to_string(),
available: daemon_running,
details: if daemon_running {
Some("Running".to_string())
} else {
Some("Not running or not accessible".to_string())
},
});
let socket_exists = std::path::Path::new("/var/run/docker.sock").exists();
deps.push(DependencyStatus {
name: "Docker socket".to_string(),
available: socket_exists,
details: Some("/var/run/docker.sock".to_string()),
});
}
"node" | "npm" => {
let npm_available = Command::new("npm")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
deps.push(DependencyStatus {
name: "npm".to_string(),
available: npm_available,
details: None,
});
}
"rust" | "cargo" => {
let rustup_available = Command::new("rustup")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
deps.push(DependencyStatus {
name: "rustup".to_string(),
available: rustup_available,
details: None,
});
}
_ => {}
}
deps
}
fn find_config_files(tool_name: &str) -> Vec<ConfigFile> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
let mut configs = Vec::new();
let paths: Vec<PathBuf> = match tool_name {
"docker" => vec![
home.join(".docker/config.json"),
home.join(".docker/daemon.json"),
],
"git" => vec![home.join(".gitconfig"), home.join(".gitignore_global")],
"node" | "npm" => vec![home.join(".npmrc"), home.join(".nvmrc")],
"rust" | "cargo" => vec![
home.join(".cargo/config.toml"),
home.join(".cargo/config"),
home.join(".rustup/settings.toml"),
],
"kubectl" | "kubernetes" => vec![home.join(".kube/config")],
_ => vec![],
};
for path in paths {
let exists = path.exists();
let size = if exists {
std::fs::metadata(&path).ok().map(|m| m.len())
} else {
None
};
configs.push(ConfigFile {
path: path.to_string_lossy().to_string(),
exists,
size,
});
}
configs
}
fn run_health_checks(
tool_name: &str,
spec: &ToolSpec,
installation: &InstallationStatus,
) -> Vec<HealthCheck> {
let mut checks = Vec::new();
if !installation.installed {
return checks;
}
checks.push(HealthCheck {
name: format!("{} --version", spec.command),
passed: installation.version.is_some(),
details: installation.version.clone(),
});
match tool_name {
"docker" => {
let ps_ok = Command::new("docker")
.args(["ps", "--format", "{{.ID}}"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
checks.push(HealthCheck {
name: "docker ps".to_string(),
passed: ps_ok,
details: if ps_ok {
None
} else {
Some("Cannot list containers".to_string())
},
});
}
"git" => {
let config_ok = Command::new("git")
.args(["config", "--get", "user.name"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
checks.push(HealthCheck {
name: "git config user.name".to_string(),
passed: config_ok,
details: if config_ok {
None
} else {
Some("User name not configured".to_string())
},
});
}
"node" => {
let exec_ok = Command::new("node")
.args(["-e", "console.log('ok')"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
checks.push(HealthCheck {
name: "node execution".to_string(),
passed: exec_ok,
details: if exec_ok {
None
} else {
Some("Cannot execute Node.js".to_string())
},
});
}
_ => {}
}
checks
}
fn check_path_issues(binary_location: &str) -> Vec<Issue> {
let mut issues = Vec::new();
let binary_dir = std::path::Path::new(binary_location)
.parent()
.map(|p| p.to_string_lossy().to_string());
if let Some(dir) = binary_dir {
if let Ok(path) = std::env::var("PATH") {
if !path.split(':').any(|p| p == dir) {
issues.push(Issue {
severity: IssueSeverity::Warning,
description: format!("Binary directory '{}' may not be in PATH", dir),
fix_id: Some("add-to-path".to_string()),
});
}
}
}
issues
}
fn print_diagnostic_report(report: &DiagnosticReport) {
println!("Installation Status");
println!("{}", "-".repeat(40));
println!(
"Installed: {}",
if report.installation.installed {
"Yes"
} else {
"No"
}
);
if let Some(ref version) = report.installation.version {
println!("Version: {}", version);
}
if let Some(ref location) = report.installation.location {
println!("Location: {}", location);
}
if let Some(ref method) = report.installation.method {
println!("Method: {}", method);
}
println!();
if let Some(ref binary) = report.binary {
println!("Binary Analysis");
println!("{}", "-".repeat(40));
println!("File type: {}", binary.file_type);
println!("Permissions: {}", binary.permissions);
println!("Owner: {}", binary.owner);
if let Some(ref target) = binary.symlink_target {
println!("Symlink: -> {}", target);
}
println!("Size: {} bytes", binary.size);
println!();
}
if !report.dependencies.is_empty() {
println!("Dependencies");
println!("{}", "-".repeat(40));
for dep in &report.dependencies {
let status = if dep.available {
"\x1b[32m[OK]\x1b[0m"
} else {
"\x1b[31m[MISSING]\x1b[0m"
};
print!("{} {}", status, dep.name);
if let Some(ref details) = dep.details {
print!(" ({})", details);
}
println!();
}
println!();
}
if !report.config_files.is_empty() {
println!("Configuration");
println!("{}", "-".repeat(40));
for config in &report.config_files {
let status = if config.exists {
"\x1b[32m[EXISTS]\x1b[0m"
} else {
"\x1b[33m[MISSING]\x1b[0m"
};
print!("{} {}", status, config.path);
if let Some(size) = config.size {
print!(" ({} bytes)", size);
}
println!();
}
println!();
}
if !report.health_checks.is_empty() {
println!("Health Checks");
println!("{}", "-".repeat(40));
for check in &report.health_checks {
let status = if check.passed {
"\x1b[32m[PASS]\x1b[0m"
} else {
"\x1b[31m[FAIL]\x1b[0m"
};
print!("{} {}", status, check.name);
if let Some(ref details) = check.details {
print!(" - {}", details);
}
println!();
}
println!();
}
if !report.issues.is_empty() {
println!("Issues Found");
println!("{}", "-".repeat(40));
for issue in &report.issues {
let icon = match issue.severity {
IssueSeverity::Error => "\x1b[31m[ERROR]\x1b[0m",
IssueSeverity::Warning => "\x1b[33m[WARN]\x1b[0m",
IssueSeverity::Info => "\x1b[34m[INFO]\x1b[0m",
};
println!("{} {}", icon, issue.description);
}
println!();
}
if !report.fixes.is_empty() {
println!("Suggested Fixes");
println!("{}", "-".repeat(40));
for (i, fix) in report.fixes.iter().enumerate() {
println!("{}. {}", i + 1, fix.description);
if let Some(ref cmd) = fix.command {
println!(" Command: {}", cmd);
}
}
println!();
}
let error_count = report
.issues
.iter()
.filter(|i| i.severity == IssueSeverity::Error)
.count();
let warning_count = report
.issues
.iter()
.filter(|i| i.severity == IssueSeverity::Warning)
.count();
if error_count == 0 && warning_count == 0 {
println!(
"\x1b[32mNo issues detected. {} is healthy.\x1b[0m",
report.tool
);
} else {
println!(
"Summary: {} error(s), {} warning(s)",
error_count, warning_count
);
}
}
fn chrono_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
format!("{}", secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_permissions() {
assert_eq!(format_permissions(0o755), "-rwxr-xr-x");
assert_eq!(format_permissions(0o644), "-rw-r--r--");
assert_eq!(format_permissions(0o700), "-rwx------");
}
#[test]
fn test_detect_install_method() {
assert_eq!(
detect_install_method(Some("/opt/homebrew/bin/git")),
Some("homebrew".to_string())
);
assert_eq!(
detect_install_method(Some("/Users/test/.cargo/bin/rustc")),
Some("cargo".to_string())
);
assert_eq!(
detect_install_method(Some("/usr/bin/ls")),
Some("system".to_string())
);
}
#[test]
fn test_issue_severity_serialization() {
let issue = Issue {
severity: IssueSeverity::Error,
description: "Test".to_string(),
fix_id: None,
};
let json = serde_json::to_string(&issue).unwrap();
assert!(json.contains("\"severity\":\"error\""));
}
}