use crate::codetime::SystemInventory;
use crate::commands::service::load_xbp_config;
use crate::commands::worktree_watch::{
collect_worktree_watch_diagnostic, WorktreeWatchDiagnostic,
};
use crate::config::{ensure_global_xbp_paths, sync_system_inventory};
use crate::logging::{log_info, log_warn};
use crate::strategies::{get_all_services, ProjectDetector};
use crate::utils::{collect_listening_port_ownership, command_exists, ListeningPortOwnership};
use anyhow::Result;
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fs;
use std::fs::OpenOptions;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use sysinfo::{Disks, Networks, System};
use tokio::process::Command;
#[derive(Debug, Serialize, Deserialize)]
pub struct SystemMetrics {
pub cpu_usage: f32,
pub memory_total: u64,
pub memory_used: u64,
pub memory_percent: f32,
pub disk_total: u64,
pub disk_used: u64,
pub disk_percent: f32,
pub network_rx: u64,
pub network_tx: u64,
pub uptime: u64,
pub process_count: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PortCheck {
pub port: u16,
pub is_open: bool,
pub is_blocked: bool,
pub pids: Vec<u32>,
pub xbp_projects: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InternetSpeed {
pub download_mbps: f64,
pub upload_mbps: f64,
pub ping_ms: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OsInfo {
pub name: Option<String>,
pub version: Option<String>,
pub kernel_version: Option<String>,
pub arch: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CpuInfo {
pub brand: Option<String>,
pub cores: usize,
pub usage: f32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DiskInfo {
pub mount: String,
pub fs: Option<String>,
pub total: u64,
pub used: u64,
pub percent: f32,
pub bar: String,
pub is_current: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ShellInfo {
pub shell: Option<String>,
pub term: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProxyDetection {
pub nginx_sites_available: Option<usize>,
pub nginx_active: Option<bool>,
pub traefik_active: Option<bool>,
pub apache_active: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExposureInfo {
pub public_ipv4: Option<String>,
pub listening_on_all_interfaces: Vec<String>,
pub firewall_hint: Option<String>,
pub summary: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ToolVersion {
pub present: bool,
pub version: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PathPermissionCheck {
pub path: String,
pub purpose: String,
pub required: String,
pub exists: bool,
pub readable: bool,
pub writable: bool,
pub creatable: bool,
pub ok: bool,
pub message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DnsCheck {
pub host: String,
pub ok: bool,
pub addresses: Vec<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HttpProbe {
pub name: String,
pub url: String,
pub ok: bool,
pub status: Option<u16>,
pub latency_ms: Option<u64>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GitHygiene {
pub is_repo: bool,
pub branch: Option<String>,
pub dirty: Option<bool>,
pub ahead: Option<u32>,
pub behind: Option<u32>,
pub remote: Option<String>,
pub notes: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CliAuthDiagnostic {
pub logged_in: bool,
pub user_email: Option<String>,
pub user_name: Option<String>,
pub token_prefix: Option<String>,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DockerDiagnostic {
pub cli_present: bool,
pub daemon_ok: bool,
pub version: Option<String>,
pub compose_present: bool,
pub note: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NetworkInterfaceInfo {
pub name: String,
pub rx_bytes: u64,
pub tx_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DiagScorecard {
pub healthy: usize,
pub warnings: usize,
pub failures: usize,
pub grade: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DiagnosticReport {
pub system_metrics: SystemMetrics,
pub os: Option<OsInfo>,
pub cpu: Option<CpuInfo>,
pub gpu_candidates: Vec<String>,
pub disks: Vec<DiskInfo>,
pub shell: Option<ShellInfo>,
pub proxy_detection: Option<ProxyDetection>,
pub clipboard_tools: HashMap<String, bool>,
pub exposure: Option<ExposureInfo>,
pub pm2_process_count: Option<usize>,
pub tool_versions: HashMap<String, ToolVersion>,
pub service_statuses: HashMap<String, String>,
pub feature_flags: HashMap<String, bool>,
pub xbp_cli_version: String,
pub hostname: Option<String>,
pub cwd: String,
pub local_ips: Vec<String>,
pub network_interfaces: Vec<NetworkInterfaceInfo>,
pub project_config_present: bool,
pub configured_services: Vec<String>,
pub provider_manifests: Vec<String>,
pub env_files: Vec<String>,
pub nginx_status: Option<NginxStatus>,
pub port_checks: Vec<PortCheck>,
pub internet_speed: Option<InternetSpeed>,
pub connectivity: bool,
pub dns_checks: Vec<DnsCheck>,
pub http_probes: Vec<HttpProbe>,
pub git: Option<GitHygiene>,
pub cli_auth: Option<CliAuthDiagnostic>,
pub docker: Option<DockerDiagnostic>,
pub installed_programs: HashMap<String, bool>,
pub path_permission_checks: Vec<PathPermissionCheck>,
pub codetime: Option<CodeTimeDiagnostic>,
pub worktree_watch: Option<WorktreeWatchDiagnostic>,
pub recommendations: Vec<String>,
pub scorecard: DiagScorecard,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CodeTimeDiagnostic {
pub saved_config_path: String,
pub refreshed: bool,
pub inventory: SystemInventory,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NginxStatus {
pub is_running: bool,
pub is_enabled: bool,
pub config_valid: bool,
pub error: Option<String>,
}
const PM2_DIAG_TIMEOUT: Duration = Duration::from_secs(12);
const PORT_OWNERSHIP_TIMEOUT: Duration = Duration::from_secs(20);
const PER_PORT_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
const TOOL_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
const PROGRAM_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
const COMMAND_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
const INTERNET_SPEED_TIMEOUT: Duration = Duration::from_secs(20);
const NGINX_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
const SYSTEM_INVENTORY_TIMEOUT: Duration = Duration::from_secs(25);
const DOWNLOAD_PROBE_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
const DOWNLOAD_PROBE_MAX_DURATION: Duration = Duration::from_secs(8);
const DOWNLOAD_PROBE_TARGET_BYTES: u64 = 8_000_000;
const DOWNLOAD_PROBE_MIN_BYTES: u64 = 1_000_000;
const DOWNLOAD_PROBE_URLS: &[&str] = &[
"https://speed.cloudflare.com/__down?bytes=20000000",
"https://speed.hetzner.de/10MB.bin",
"https://proof.ovh.net/files/10Mb.dat",
];
#[derive(Debug, Clone, Copy, Default)]
pub struct DiagnosticRunOptions {
pub skip_speed_test: bool,
pub codetime: bool,
pub cursor: bool,
pub quick: bool,
pub full: bool,
pub http_probe: bool,
}
pub async fn get_system_metrics() -> Result<SystemMetrics> {
let mut sys = System::new_all();
sys.refresh_all();
let cpu_usage =
sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
let memory_total = sys.total_memory();
let memory_used = sys.used_memory();
let memory_percent = (memory_used as f32 / memory_total as f32) * 100.0;
let mut disk_total = 0;
let mut disk_used = 0;
for disk in Disks::new_with_refreshed_list().iter() {
disk_total += disk.total_space();
disk_used += disk.total_space() - disk.available_space();
}
let disk_percent = if disk_total > 0 {
(disk_used as f32 / disk_total as f32) * 100.0
} else {
0.0
};
let networks = Networks::new_with_refreshed_list();
let mut network_rx = 0;
let mut network_tx = 0;
for (_interface_name, network) in &networks {
network_rx += network.total_received();
network_tx += network.total_transmitted();
}
let uptime = System::uptime();
let process_count = sys.processes().len();
Ok(SystemMetrics {
cpu_usage,
memory_total,
memory_used,
memory_percent,
disk_total,
disk_used,
disk_percent,
network_rx,
network_tx,
uptime,
process_count,
})
}
pub async fn check_nginx_status() -> Result<NginxStatus> {
let is_running: bool = check_systemctl_status("nginx").await?;
let is_enabled: bool = check_systemctl_enabled("nginx").await?;
let config_valid: bool = if is_running {
match Command::new("nginx").arg("-t").output().await {
Ok(output) => output.status.success(),
Err(_) => false,
}
} else {
false
};
Ok(NginxStatus {
is_running,
is_enabled,
config_valid,
error: None,
})
}
async fn check_systemctl_status(service: &str) -> Result<bool> {
if !cfg!(target_os = "linux") || !command_exists("systemctl") {
return Ok(false);
}
let output = Command::new("systemctl")
.arg("is-active")
.arg(service)
.output()
.await?;
Ok(output.status.success())
}
async fn check_systemctl_enabled(service: &str) -> Result<bool> {
if !cfg!(target_os = "linux") || !command_exists("systemctl") {
return Ok(false);
}
let output = Command::new("systemctl")
.arg("is-enabled")
.arg(service)
.output()
.await?;
Ok(output.status.success())
}
pub async fn check_port_availability(
port: u16,
ownership: Option<&ListeningPortOwnership>,
) -> Result<PortCheck> {
use std::net::TcpListener;
let is_open: bool = TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok();
let is_blocked: bool = if cfg!(target_os = "linux") {
check_firewall_blocked(port).await.unwrap_or(false)
} else {
false
};
Ok(PortCheck {
port,
is_open,
is_blocked,
pids: ownership
.map(|value| value.pids.clone())
.unwrap_or_default(),
xbp_projects: ownership
.map(|value| value.xbp_projects.clone())
.unwrap_or_default(),
})
}
async fn check_firewall_blocked(port: u16) -> Result<bool> {
let output: std::process::Output = Command::new("iptables")
.arg("-L")
.arg("-n")
.output()
.await?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let port_str = port.to_string();
Ok(stdout.contains(&format!("dpt:{}", port_str)) && stdout.contains("DROP"))
}
pub async fn check_internet_connectivity() -> Result<bool> {
let result: std::process::Output = if cfg!(target_os = "windows") {
Command::new("ping")
.arg("-n")
.arg("1")
.arg("-w")
.arg("2000")
.arg("8.8.8.8")
.output()
.await?
} else {
Command::new("ping")
.arg("-c")
.arg("1")
.arg("-W")
.arg("2")
.arg("8.8.8.8")
.output()
.await?
};
Ok(result.status.success())
}
pub async fn measure_internet_speed() -> Result<InternetSpeed> {
if let Ok(speed) = measure_speed_with_speedtest_cli().await {
if speed.download_mbps >= 1.0 || speed.upload_mbps >= 0.5 {
return Ok(speed);
}
}
let ping: f64 = measure_ping().await?;
let download_mbps = measure_download_speed().await.unwrap_or(0.0);
let upload_mbps = 0.0;
Ok(InternetSpeed {
download_mbps,
upload_mbps,
ping_ms: ping,
})
}
fn parse_speed_value(token: &str) -> Option<f64> {
token.trim().replace(',', ".").parse::<f64>().ok()
}
async fn measure_speed_with_speedtest_cli() -> Result<InternetSpeed> {
let output: std::process::Output = Command::new("speedtest-cli")
.arg("--simple")
.output()
.await?;
if !output.status.success() {
return Err(anyhow::anyhow!("speedtest-cli failed"));
}
let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
let mut ping_ms: f64 = 0.0;
let mut download_mbps: f64 = 0.0;
let mut upload_mbps: f64 = 0.0;
for line in stdout.lines() {
if line.starts_with("Ping:") {
if let Some(val) = line.split_whitespace().nth(1) {
ping_ms = parse_speed_value(val).unwrap_or(0.0);
}
} else if line.starts_with("Download:") {
if let Some(val) = line.split_whitespace().nth(1) {
download_mbps = parse_speed_value(val).unwrap_or(0.0);
}
} else if line.starts_with("Upload:") {
if let Some(val) = line.split_whitespace().nth(1) {
upload_mbps = parse_speed_value(val).unwrap_or(0.0);
}
}
}
Ok(InternetSpeed {
download_mbps,
upload_mbps,
ping_ms,
})
}
async fn measure_ping() -> Result<f64> {
let output: std::process::Output = if cfg!(target_os = "windows") {
Command::new("ping")
.arg("-n")
.arg("4")
.arg("8.8.8.8")
.output()
.await?
} else {
Command::new("ping")
.arg("-c")
.arg("4")
.arg("8.8.8.8")
.output()
.await?
};
if !output.status.success() {
return Ok(0.0);
}
let stdout = String::from_utf8_lossy(&output.stdout);
if cfg!(target_os = "windows") {
for line in stdout.lines() {
if line.contains("Average") {
if let Some(avg_part) = line.split('=').next_back() {
let avg_str = avg_part.trim().trim_end_matches("ms");
if let Ok(avg) = avg_str.parse::<f64>() {
return Ok(avg);
}
}
}
}
} else {
for line in stdout.lines() {
if line.contains("avg") || line.contains("rtt") {
if let Some(avg_str) = line.split('/').nth(4) {
if let Ok(avg) = avg_str.trim().parse::<f64>() {
return Ok(avg);
}
}
}
}
}
Ok(0.0)
}
async fn measure_download_speed() -> Result<f64> {
let client: reqwest::Client = reqwest::Client::builder()
.timeout(DOWNLOAD_PROBE_REQUEST_TIMEOUT)
.build()?;
let mut last_error: Option<String> = None;
for url in DOWNLOAD_PROBE_URLS {
match measure_download_speed_probe(&client, url).await {
Ok(mbps) => return Ok(mbps),
Err(err) => last_error = Some(err.to_string()),
}
}
Err(anyhow::anyhow!(
"all download probes failed{}",
last_error
.map(|value| format!(" (last error: {})", value))
.unwrap_or_default()
))
}
async fn measure_download_speed_probe(client: &reqwest::Client, url: &str) -> Result<f64> {
let start = Instant::now();
let mut response: reqwest::Response = client
.get(url)
.header("Cache-Control", "no-cache")
.header("Pragma", "no-cache")
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"download probe {} returned status {}",
url,
response.status()
));
}
let mut total_bytes: u64 = 0;
loop {
if total_bytes >= DOWNLOAD_PROBE_TARGET_BYTES
|| start.elapsed() >= DOWNLOAD_PROBE_MAX_DURATION
{
break;
}
let chunk = response.chunk().await?;
let Some(bytes) = chunk else {
break;
};
total_bytes = total_bytes.saturating_add(bytes.len() as u64);
}
if total_bytes < DOWNLOAD_PROBE_MIN_BYTES {
return Err(anyhow::anyhow!(
"insufficient sample from {}: {} bytes",
url,
total_bytes
));
}
let elapsed_seconds = start.elapsed().as_secs_f64().max(0.001);
let megabits = (total_bytes as f64 * 8.0) / 1_000_000.0;
Ok(megabits / elapsed_seconds)
}
pub async fn check_installed_programs() -> HashMap<String, bool> {
let programs = vec![
"jq",
"curl",
"wget",
"nginx",
"git",
"docker",
"docker-compose",
"podman",
"kubectl",
"microk8s",
"helm",
"node",
"npm",
"pnpm",
"yarn",
"bun",
"python",
"python3",
"pip",
"pip3",
"uv",
"cargo",
"rustc",
"rustup",
"pm2",
"systemctl",
"launchctl",
"ss",
"lsof",
"nc",
"nmap",
"openssl",
"dig",
"nslookup",
"cloudflared",
"wrangler",
"gh",
"rg",
"fd",
"make",
"cmake",
"speedtest-cli",
];
let mut results = HashMap::new();
for program in programs {
let is_installed = check_program_installed(program).await;
results.insert(program.to_string(), is_installed);
}
results
}
fn can_create_file_in_dir(dir: &std::path::Path) -> bool {
use std::time::{SystemTime, UNIX_EPOCH};
if !dir.is_dir() {
return false;
}
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let probe = dir.join(format!(
".xbp_diag_perm_probe_{}_{}",
std::process::id(),
nonce
));
match OpenOptions::new().write(true).create_new(true).open(&probe) {
Ok(_) => {
let _ = fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn check_path_permission_entry(
path: PathBuf,
purpose: &str,
require_read: bool,
require_write: bool,
require_create: bool,
optional: bool,
) -> PathPermissionCheck {
let exists = path.exists();
let mut readable = false;
let mut writable = false;
let mut creatable = false;
let mut message = None;
if exists {
if path.is_dir() {
readable = fs::read_dir(&path).is_ok();
writable = can_create_file_in_dir(&path);
creatable = writable;
} else if path.is_file() {
readable = fs::File::open(&path).is_ok();
writable = OpenOptions::new().write(true).open(&path).is_ok();
if let Some(parent) = path.parent() {
creatable = can_create_file_in_dir(parent);
}
} else {
message = Some("Path exists but is not a regular file/directory".to_string());
}
} else if let Some(parent) = path.parent() {
creatable = can_create_file_in_dir(parent);
readable = parent.exists() && fs::read_dir(parent).is_ok();
writable = parent.exists() && can_create_file_in_dir(parent);
if optional {
message = Some("Path missing (optional)".to_string());
} else if require_create && !creatable {
message =
Some("Path missing and cannot be created with current permissions".to_string());
} else {
message = Some("Path missing".to_string());
}
} else if optional {
message = Some("Path missing (optional)".to_string());
} else {
message = Some("Path missing".to_string());
}
let mut missing = Vec::new();
if require_read && !readable {
missing.push("read");
}
if require_write && !writable {
missing.push("write");
}
if require_create && !creatable {
missing.push("create");
}
let mut ok = missing.is_empty();
if optional && !exists {
ok = true;
}
if !ok {
let detail = format!("Missing required access: {}", missing.join(", "));
message = match message {
Some(existing) => Some(format!("{}; {}", existing, detail)),
None => Some(detail),
};
}
let required = match (require_read, require_write, require_create) {
(true, true, true) => "read/write/create",
(true, true, false) => "read/write",
(true, false, true) => "read/create",
(false, true, true) => "write/create",
(true, false, false) => "read",
(false, true, false) => "write",
(false, false, true) => "create",
(false, false, false) => "none",
}
.to_string();
PathPermissionCheck {
path: path.display().to_string(),
purpose: purpose.to_string(),
required,
exists,
readable,
writable,
creatable,
ok,
message,
}
}
async fn get_path_permission_checks() -> Vec<PathPermissionCheck> {
let mut checks = Vec::new();
if cfg!(target_os = "linux") {
checks.push(check_path_permission_entry(
PathBuf::from("/etc/systemd/system"),
"Install/update systemd unit files",
true,
true,
true,
false,
));
checks.push(check_path_permission_entry(
PathBuf::from("/etc/default/xbp"),
"Optional runtime environment file for xbp-api.service",
true,
true,
true,
true,
));
checks.push(check_path_permission_entry(
PathBuf::from("/etc/nginx"),
"Read/write Nginx configuration root",
true,
true,
false,
false,
));
checks.push(check_path_permission_entry(
PathBuf::from("/etc/nginx/sites-available"),
"Manage Nginx site configurations",
true,
true,
true,
false,
));
checks.push(check_path_permission_entry(
PathBuf::from("/etc/nginx/sites-enabled"),
"Enable/disable Nginx sites",
true,
true,
true,
false,
));
checks.push(check_path_permission_entry(
PathBuf::from("/var/log"),
"Write XBP and service log files",
true,
true,
true,
false,
));
checks.push(check_path_permission_entry(
PathBuf::from("/var/lib/xbp"),
"State directory used by generated systemd services",
true,
true,
true,
true,
));
checks.push(check_path_permission_entry(
PathBuf::from("/var/log/nginx"),
"Read Nginx access/error logs for diagnostics and metrics",
true,
true,
true,
true,
));
checks.push(check_path_permission_entry(
PathBuf::from("/run"),
"Runtime sockets/state files used by services",
true,
true,
true,
false,
));
checks.push(check_path_permission_entry(
PathBuf::from("/usr/local/bin/xbp"),
"Installed XBP CLI binary path",
true,
true,
true,
true,
));
} else if let Ok(current) = env::current_dir() {
checks.push(check_path_permission_entry(
current,
"Current project directory",
true,
true,
true,
false,
));
}
if let Ok(current) = env::current_dir() {
checks.push(check_path_permission_entry(
current.join(".xbp"),
"Local XBP project metadata directory",
true,
true,
true,
true,
));
}
if let Ok(paths) = ensure_global_xbp_paths() {
checks.push(check_path_permission_entry(
paths.root_dir.clone(),
"Global XBP home (config + worktree-watch mutations)",
true,
true,
true,
true,
));
checks.push(check_path_permission_entry(
paths.root_dir.join("mutations"),
"Worktree-watch mutation spool root under global XBP home",
true,
true,
true,
true,
));
}
checks
}
async fn run_command_with_timeout(
program: &str,
args: &[&str],
timeout: Duration,
) -> Option<std::process::Output> {
let mut cmd = Command::new(program);
cmd.args(args);
cmd.kill_on_drop(true);
match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(output)) => Some(output),
_ => None,
}
}
async fn check_program_installed(program: &str) -> bool {
let output = if cfg!(target_os = "windows") {
run_command_with_timeout("where", &[program], PROGRAM_CHECK_TIMEOUT).await
} else {
run_command_with_timeout("which", &[program], PROGRAM_CHECK_TIMEOUT).await
};
output.map(|value| value.status.success()).unwrap_or(false)
}
fn render_percent_bar(percent: f32, width: usize) -> String {
let pct = percent.clamp(0.0, 100.0);
let filled = ((pct / 100.0) * width as f32).round() as usize;
let filled = filled.min(width);
let empty = width.saturating_sub(filled);
format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty))
}
async fn get_os_info() -> Option<OsInfo> {
Some(OsInfo {
name: System::name(),
version: System::os_version(),
kernel_version: System::kernel_version(),
arch: Some(std::env::consts::ARCH.to_string()),
})
}
async fn get_cpu_info() -> Option<CpuInfo> {
let mut sys = System::new_all();
sys.refresh_cpu_all();
let cores = sys.cpus().len();
let usage = if cores > 0 {
sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / cores as f32
} else {
0.0
};
let brand = sys.cpus().first().map(|c| c.brand().to_string());
Some(CpuInfo {
brand,
cores,
usage,
})
}
async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
let output = run_command_with_timeout(program, args, COMMAND_CAPTURE_TIMEOUT).await?;
let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
let combined: String = if !stdout.is_empty() { stdout } else { stderr };
if combined.is_empty() {
None
} else {
Some(combined)
}
}
async fn get_gpu_candidates() -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if cfg!(target_os = "windows") {
if let Some(s) = run_command_capture(
"powershell",
&[
"-Command",
"Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
],
)
.await
{
for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
out.push(line.to_string());
}
}
return out;
}
if let Some(s) = run_command_capture("nvidia-smi", &["-L"]).await {
for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
out.push(line.to_string());
}
}
if out.is_empty() {
if let Some(s) =
run_command_capture("sh", &["-c", "lspci | grep -Ei 'vga|3d|display'"]).await
{
for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
out.push(line.to_string());
}
}
}
if out.is_empty() {
if let Some(s) =
run_command_capture("sh", &["-c", "lshw -C display 2>/dev/null | head"]).await
{
for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
out.push(line.to_string());
}
}
}
out
}
async fn get_disk_infos() -> Vec<DiskInfo> {
let disks = Disks::new_with_refreshed_list();
let cwd = env::current_dir().ok();
let mut candidates: Vec<(usize, usize)> = Vec::new();
if let Some(cwd) = &cwd {
for (i, d) in disks.iter().enumerate() {
let mp = d.mount_point();
if cwd.starts_with(mp) {
candidates.push((mp.as_os_str().len(), i));
}
}
}
candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
let current_idx = candidates.first().map(|(_, idx)| *idx);
disks
.iter()
.enumerate()
.map(|(i, d)| {
let total = d.total_space();
let used = total.saturating_sub(d.available_space());
let percent = if total > 0 {
(used as f32 / total as f32) * 100.0
} else {
0.0
};
let mount = d.mount_point().to_string_lossy().to_string();
let fs = if d.file_system().is_empty() {
None
} else {
Some(d.file_system().to_string_lossy().to_string())
};
DiskInfo {
mount,
fs,
total,
used,
percent,
bar: render_percent_bar(percent, 20),
is_current: current_idx == Some(i),
}
})
.collect()
}
fn get_shell_info() -> Option<ShellInfo> {
let shell = env::var("SHELL")
.ok()
.or_else(|| env::var("ComSpec").ok())
.or_else(|| env::var("0").ok());
let term = env::var("TERM").ok();
if shell.is_none() && term.is_none() {
None
} else {
Some(ShellInfo { shell, term })
}
}
async fn systemctl_active(service: &str) -> Option<bool> {
if !cfg!(target_os = "linux") || !command_exists("systemctl") {
return None;
}
let output = Command::new("systemctl")
.arg("is-active")
.arg(service)
.output()
.await
.ok()?;
Some(output.status.success())
}
async fn get_service_statuses() -> HashMap<String, String> {
let mut out = HashMap::new();
let services = [
"nginx",
"traefik",
"caddy",
"apache2",
"httpd",
"postgresql",
"postgres",
"mysql",
"mariadb",
"redis",
"redis-server",
"postgrest",
"prometheus",
"grafana-server",
"grafana",
"docker",
"containerd",
"ssh",
"sshd",
"fail2ban",
"kafka",
"xbp",
"xbp-api",
"xbp-daemon",
];
for s in services {
let v = systemctl_active(s).await;
let status = match v {
Some(true) => "active",
Some(false) => "inactive",
None => "unknown",
};
out.insert(s.to_string(), status.to_string());
}
out
}
async fn get_proxy_detection() -> Option<ProxyDetection> {
let nginx_sites_available = if cfg!(target_os = "linux") {
let p = PathBuf::from("/etc/nginx/sites-available");
if p.exists() {
fs::read_dir(&p)
.ok()
.map(|rd| rd.filter_map(|e| e.ok()).count())
} else {
None
}
} else {
None
};
let nginx_active = systemctl_active("nginx").await;
let traefik_active = systemctl_active("traefik").await;
let apache_active = match systemctl_active("apache2").await {
Some(v) => Some(v),
None => systemctl_active("httpd").await,
};
if nginx_sites_available.is_none()
&& nginx_active.is_none()
&& traefik_active.is_none()
&& apache_active.is_none()
{
None
} else {
Some(ProxyDetection {
nginx_sites_available,
nginx_active,
traefik_active,
apache_active,
})
}
}
async fn get_clipboard_tools() -> HashMap<String, bool> {
let mut tools = vec!["xclip", "xsel", "wl-copy", "pbcopy"];
if cfg!(target_os = "windows") {
tools.push("clip");
}
let mut out = HashMap::new();
for t in tools {
out.insert(t.to_string(), check_program_installed(t).await);
}
out
}
fn get_listeners_on_all_interfaces() -> Vec<String> {
let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
let proto_flags = ProtocolFlags::TCP;
let sockets = get_sockets_info(af_flags, proto_flags).unwrap_or_default();
let mut out = Vec::new();
for socket in sockets {
if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
let state = format!("{:?}", tcp.state);
if state != "Listen" && state != "LISTEN" {
continue;
}
let addr = tcp.local_addr.to_string();
if addr == "0.0.0.0" || addr == "::" {
out.push(format!("{}:{}", addr, tcp.local_port));
}
}
}
out.sort();
out.dedup();
out
}
async fn get_firewall_hint() -> Option<String> {
if !cfg!(target_os = "linux") {
return None;
}
if let Some(s) = run_command_capture("sh", &["-c", "ufw status 2>/dev/null | head -n 1"]).await
{
if !s.trim().is_empty() {
return Some(s.trim().to_string());
}
}
if let Some(s) =
run_command_capture("sh", &["-c", "nft list ruleset 2>/dev/null | head -n 1"]).await
{
if !s.trim().is_empty() {
return Some("nftables detected".to_string());
}
}
if let Some(s) = run_command_capture("sh", &["-c", "iptables -S 2>/dev/null | head -n 1"]).await
{
if !s.trim().is_empty() {
return Some("iptables detected".to_string());
}
}
None
}
async fn get_public_ipv4(connectivity: bool) -> Option<String> {
if !connectivity {
return None;
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
.ok()?;
let text = client
.get("https://api.ipify.org")
.send()
.await
.ok()?
.text()
.await
.ok()?;
let ip = text.trim().to_string();
if ip.is_empty() {
None
} else {
Some(ip)
}
}
async fn get_exposure_info(connectivity: bool) -> Option<ExposureInfo> {
let public_ipv4 = get_public_ipv4(connectivity).await;
let listening_on_all_interfaces = get_listeners_on_all_interfaces();
let firewall_hint = get_firewall_hint().await;
let summary = Some(if listening_on_all_interfaces.is_empty() {
"No listeners on 0.0.0.0/:: detected".to_string()
} else {
"Listeners on 0.0.0.0/:: detected (may be publicly reachable)".to_string()
});
Some(ExposureInfo {
public_ipv4,
listening_on_all_interfaces,
firewall_hint,
summary,
})
}
async fn get_pm2_process_count() -> Option<usize> {
let mut cmd = if cfg!(target_os = "windows") {
let mut cmd = Command::new("powershell");
cmd.arg("-NoProfile").arg("-Command").arg("pm2 jlist");
cmd
} else {
let mut cmd = Command::new("pm2");
cmd.arg("jlist");
cmd
};
cmd.kill_on_drop(true);
let output = match tokio::time::timeout(PM2_DIAG_TIMEOUT, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(_)) | Err(_) => return None,
};
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).ok()?;
value.as_array().map(|arr| arr.len())
}
async fn get_version(cmd: &str, args: &[&str]) -> ToolVersion {
match run_command_with_timeout(cmd, args, TOOL_VERSION_TIMEOUT).await {
Some(o) => {
let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
let line = stdout
.lines()
.next()
.filter(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
.or_else(|| {
stderr
.lines()
.next()
.filter(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
});
ToolVersion {
present: o.status.success() || line.is_some(),
version: line,
}
}
None => ToolVersion {
present: false,
version: None,
},
}
}
async fn get_tool_versions() -> HashMap<String, ToolVersion> {
let mut out = HashMap::new();
out.insert(
"python3".to_string(),
get_version("python3", &["--version"]).await,
);
out.insert(
"python".to_string(),
get_version("python", &["--version"]).await,
);
out.insert(
"node".to_string(),
get_version("node", &["--version"]).await,
);
out.insert("npm".to_string(), get_version("npm", &["--version"]).await);
out.insert(
"pnpm".to_string(),
get_version("pnpm", &["--version"]).await,
);
out.insert("pm2".to_string(), get_version("pm2", &["--version"]).await);
out.insert(
"docker".to_string(),
get_version("docker", &["--version"]).await,
);
out.insert(
"docker-compose".to_string(),
get_version("docker-compose", &["--version"]).await,
);
out.insert(
"kubectl".to_string(),
get_version("kubectl", &["version", "--client"]).await,
);
out.insert(
"microk8s".to_string(),
get_version("microk8s", &["version"]).await,
);
out.insert(
"rustc".to_string(),
get_version("rustc", &["--version"]).await,
);
out.insert(
"cargo".to_string(),
get_version("cargo", &["--version"]).await,
);
out.insert(
"rustup".to_string(),
get_version("rustup", &["--version"]).await,
);
out.insert("git".to_string(), get_version("git", &["--version"]).await);
out.insert(
"wrangler".to_string(),
get_version("wrangler", &["--version"]).await,
);
out.insert(
"cloudflared".to_string(),
get_version("cloudflared", &["--version"]).await,
);
out.insert("gh".to_string(), get_version("gh", &["--version"]).await);
out.insert(
"openssl".to_string(),
get_version("openssl", &["version"]).await,
);
out.insert(
"bun".to_string(),
get_version("bun", &["--version"]).await,
);
out
}
async fn collect_local_ips() -> Vec<String> {
let mut ips = Vec::new();
let networks = Networks::new_with_refreshed_list();
for (name, data) in &networks {
let _ = name;
for addr in data.ip_networks() {
let s = addr.addr.to_string();
if s == "127.0.0.1" || s == "::1" || s.starts_with("fe80:") {
continue;
}
ips.push(s);
}
}
ips.sort();
ips.dedup();
ips
}
fn collect_network_interfaces() -> Vec<NetworkInterfaceInfo> {
let networks = Networks::new_with_refreshed_list();
let mut out = Vec::new();
for (name, data) in &networks {
out.push(NetworkInterfaceInfo {
name: name.clone(),
rx_bytes: data.total_received(),
tx_bytes: data.total_transmitted(),
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn discover_env_files(cwd: &std::path::Path) -> Vec<String> {
let candidates = [
".env",
".env.local",
".env.production",
".dev.vars",
".xbp/.env",
"apps/web/.env.local",
"apps/web/.dev.vars",
"apps/docs/.env.local",
];
candidates
.iter()
.filter_map(|rel| {
let p = cwd.join(rel);
if p.exists() {
Some(rel.to_string())
} else {
None
}
})
.collect()
}
async fn collect_dns_checks(hosts: &[&str]) -> Vec<DnsCheck> {
let mut out = Vec::new();
for host in hosts {
let host = (*host).to_string();
match tokio::net::lookup_host(format!("{host}:80")).await {
Ok(addrs) => {
let addresses: Vec<String> = addrs.map(|a| a.ip().to_string()).collect();
out.push(DnsCheck {
host,
ok: !addresses.is_empty(),
addresses,
error: None,
});
}
Err(err) => out.push(DnsCheck {
host,
ok: false,
addresses: Vec::new(),
error: Some(err.to_string()),
}),
}
}
out
}
async fn collect_http_probes(services: &[(String, u16, Option<String>)]) -> Vec<HttpProbe> {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(4))
.redirect(reqwest::redirect::Policy::limited(3))
.build()
{
Ok(c) => c,
Err(err) => {
return vec![HttpProbe {
name: "http-client".into(),
url: String::new(),
ok: false,
status: None,
latency_ms: None,
error: Some(err.to_string()),
}];
}
};
let mut out = Vec::new();
for (name, port, url_hint) in services {
let candidates = [
url_hint
.as_ref()
.map(|u| u.trim_end_matches('/').to_string())
.filter(|u| !u.is_empty()),
Some(format!("http://127.0.0.1:{port}/")),
Some(format!("http://127.0.0.1:{port}/health")),
Some(format!("http://127.0.0.1:{port}/api/health")),
];
let mut probed = false;
for url in candidates.into_iter().flatten() {
let started = Instant::now();
match client.get(&url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let ok = status < 500;
out.push(HttpProbe {
name: name.clone(),
url,
ok,
status: Some(status),
latency_ms: Some(started.elapsed().as_millis() as u64),
error: None,
});
probed = true;
break;
}
Err(_) => continue,
}
}
if !probed {
out.push(HttpProbe {
name: name.clone(),
url: format!("http://127.0.0.1:{port}/"),
ok: false,
status: None,
latency_ms: None,
error: Some("no successful HTTP response".into()),
});
}
}
out
}
async fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn collect_git_hygiene(cwd: &std::path::Path) -> Option<GitHygiene> {
let inside = git_stdout(cwd, &["rev-parse", "--is-inside-work-tree"]).await;
if inside.as_deref() != Some("true") && !cwd.join(".git").exists() {
return Some(GitHygiene {
is_repo: false,
branch: None,
dirty: None,
ahead: None,
behind: None,
remote: None,
notes: vec!["Not a git repository".into()],
});
}
let branch = git_stdout(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
let status = git_stdout(cwd, &["status", "--porcelain"]).await;
let dirty = status.as_ref().map(|s| !s.is_empty());
let remote = git_stdout(cwd, &["remote", "get-url", "origin"]).await;
let mut notes = Vec::new();
if dirty == Some(true) {
notes.push("Working tree has uncommitted changes".into());
}
Some(GitHygiene {
is_repo: true,
branch,
dirty,
ahead: None,
behind: None,
remote,
notes,
})
}
async fn collect_cli_auth_diagnostic() -> CliAuthDiagnostic {
match crate::commands::cli_session::require_authenticated_cli_session().await {
Ok(session) => CliAuthDiagnostic {
logged_in: true,
user_email: Some(session.user.email),
user_name: Some(session.user.name),
token_prefix: Some(session.token.prefix),
message: "CLI session valid".into(),
},
Err(err) => CliAuthDiagnostic {
logged_in: false,
user_email: None,
user_name: None,
token_prefix: None,
message: err,
},
}
}
async fn collect_docker_diagnostic() -> DockerDiagnostic {
let cli_present = check_program_installed("docker").await;
if !cli_present {
return DockerDiagnostic {
cli_present: false,
daemon_ok: false,
version: None,
compose_present: check_program_installed("docker-compose").await
|| check_program_installed("docker").await,
note: Some("docker CLI not found on PATH".into()),
};
}
let version = get_version("docker", &["--version"]).await;
let daemon = Command::new("docker")
.args(["info", "--format", "{{.ServerVersion}}"])
.output()
.await;
let daemon_ok = daemon
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);
let compose_present = check_program_installed("docker-compose").await
|| Command::new("docker")
.args(["compose", "version"])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
DockerDiagnostic {
cli_present: true,
daemon_ok,
version: version.version,
compose_present,
note: if daemon_ok {
None
} else {
Some("docker daemon not reachable (is Docker running?)".into())
},
}
}
fn build_recommendations(report: &DiagnosticReport) -> Vec<String> {
let mut recs = Vec::new();
if !report.connectivity {
recs.push("Network offline — check interface, VPN, or firewall.".into());
}
if !report.project_config_present {
recs.push("No XBP project config — run `xbp init` or open a project with `.xbp/xbp.yaml`.".into());
}
if let Some(auth) = &report.cli_auth {
if !auth.logged_in {
recs.push("CLI not logged in — run `xbp login` for dashboard features.".into());
}
}
if let Some(docker) = &report.docker {
if docker.cli_present && !docker.daemon_ok {
recs.push("Docker CLI present but daemon unavailable — start Docker Desktop/service.".into());
}
}
let disk_warn = report.disks.iter().any(|d| d.percent > 85.0);
if disk_warn || report.system_metrics.disk_percent > 85.0 {
recs.push("Disk usage high (>85%) — free space before large builds/deploys.".into());
}
if report.system_metrics.memory_percent > 90.0 {
recs.push("Memory pressure high — close unused processes before heavy jobs.".into());
}
let blocked = report.port_checks.iter().filter(|p| p.is_blocked).count();
if blocked > 0 {
recs.push(format!(
"{blocked} port(s) appear firewall-blocked — review iptables/ufw/Windows Firewall."
));
}
if let Some(nginx) = &report.nginx_status {
if nginx.is_running && !nginx.config_valid {
recs.push("Nginx running with invalid config — run `nginx -t` and `xbp nginx`.".into());
}
}
for probe in &report.http_probes {
if !probe.ok {
recs.push(format!(
"HTTP probe failed for `{}` ({}) — check process and port.",
probe.name, probe.url
));
}
}
for dns in &report.dns_checks {
if !dns.ok {
recs.push(format!(
"DNS lookup failed for `{}` — check resolver / VPN split DNS.",
dns.host
));
}
}
if let Some(git) = &report.git {
if git.is_repo && git.dirty == Some(true) {
recs.push("Git working tree dirty — commit or stash before release/deploy.".into());
}
}
if !report.installed_programs.get("git").copied().unwrap_or(false) {
recs.push("git not found on PATH — install git for version/release workflows.".into());
}
if recs.is_empty() {
recs.push("No critical issues detected — host looks ready for XBP ops.".into());
}
recs
}
fn build_scorecard(report: &DiagnosticReport) -> DiagScorecard {
let mut healthy = 0usize;
let mut warnings = 0usize;
let mut failures = 0usize;
if report.connectivity {
healthy += 1;
} else {
failures += 1;
}
if report.project_config_present {
healthy += 1;
} else {
warnings += 1;
}
let path_fail = report
.path_permission_checks
.iter()
.filter(|p| !p.ok)
.count();
if path_fail == 0 {
healthy += 1;
} else {
warnings += 1;
}
let blocked = report.port_checks.iter().filter(|p| p.is_blocked).count();
if blocked == 0 {
healthy += 1;
} else {
failures += 1;
}
if report.system_metrics.disk_percent > 90.0 {
failures += 1;
} else if report.system_metrics.disk_percent > 80.0 {
warnings += 1;
} else {
healthy += 1;
}
if let Some(auth) = &report.cli_auth {
if auth.logged_in {
healthy += 1;
} else {
warnings += 1;
}
}
let http_fail = report.http_probes.iter().filter(|p| !p.ok).count();
if !report.http_probes.is_empty() {
if http_fail == 0 {
healthy += 1;
} else if http_fail < report.http_probes.len() {
warnings += 1;
} else {
failures += 1;
}
}
let grade = if failures > 0 {
"FAIL"
} else if warnings > 0 {
"WARN"
} else {
"OK"
}
.to_string();
DiagScorecard {
healthy,
warnings,
failures,
grade,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SectionState {
Healthy,
Warn,
Fail,
Info,
}
fn section_badge(state: SectionState) -> colored::ColoredString {
match state {
SectionState::Healthy => "OK".green().bold(),
SectionState::Warn => "WARN".yellow().bold(),
SectionState::Fail => "FAIL".red().bold(),
SectionState::Info => "INFO".cyan().bold(),
}
}
fn print_section_header(title: &str, state: SectionState, summary: &str) {
println!(
"\n{} {} {}",
section_badge(state),
title.bright_white().bold(),
summary.bright_black()
);
}
fn diag_bool(value: bool) -> colored::ColoredString {
if value {
"true".green()
} else {
"false".red()
}
}
fn diag_bool_fragment(label: &str, value: bool) -> String {
format!("{}={}", label, diag_bool(value))
}
fn diag_ratio(passing: usize, total: usize) -> String {
format!(
"{}{}",
passing.to_string().green(),
format!("/{}", total).bright_white()
)
}
fn color_service_status(status: &str) -> colored::ColoredString {
let lower = status.to_ascii_lowercase();
if lower.contains("running") || lower.contains("active") || lower.contains("online") {
status.green()
} else if lower.contains("stopped") || lower.contains("failed") || lower.contains("error") {
status.red()
} else if lower.contains("degraded") || lower.contains("warn") {
status.yellow()
} else {
status.bright_white()
}
}
fn render_feature_flags(flags: &HashMap<String, bool>) -> String {
let mut items = flags
.iter()
.map(|(name, value)| format!("{}={}", name, diag_bool(*value)))
.collect::<Vec<_>>();
items.sort();
items.join(" ")
}
fn summarize_path_permissions(report: &DiagnosticReport) -> (usize, usize) {
let total = report.path_permission_checks.len();
let passing = report
.path_permission_checks
.iter()
.filter(|item| item.ok)
.count();
(passing, total)
}
fn summarize_port_checks(report: &DiagnosticReport) -> (usize, usize, usize) {
let total = report.port_checks.len();
let open = report
.port_checks
.iter()
.filter(|item| item.is_open)
.count();
let blocked = report
.port_checks
.iter()
.filter(|item| item.is_blocked)
.count();
(open, blocked, total)
}
pub async fn print_diagnostic_report(report: &DiagnosticReport) {
let feature_flags = render_feature_flags(&report.feature_flags);
let grade_state = match report.scorecard.grade.as_str() {
"OK" => SectionState::Healthy,
"WARN" => SectionState::Warn,
_ => SectionState::Fail,
};
print_section_header(
"Scorecard",
grade_state,
&format!(
"grade={} | healthy={} warn={} fail={}",
report.scorecard.grade,
report.scorecard.healthy,
report.scorecard.warnings,
report.scorecard.failures
),
);
println!(
" {} {}",
"Working directory:".bright_white(),
report.cwd.bright_cyan()
);
if let Some(host) = &report.hostname {
println!(" {} {}", "Hostname:".bright_white(), host);
}
if !report.local_ips.is_empty() {
println!(
" {} {}",
"Local IPs:".bright_white(),
report.local_ips.join(", ")
);
}
let env_summary = format!(
"v{} | {} | connectivity {}",
report.xbp_cli_version,
feature_flags,
if report.connectivity {
"online"
} else {
"offline"
}
);
print_section_header(
"Environment",
if report.connectivity {
SectionState::Healthy
} else {
SectionState::Warn
},
&env_summary,
);
println!(
" {} {}",
"XBP version:".bright_white(),
report.xbp_cli_version.bright_cyan()
);
println!(" {} {}", "Feature flags:".bright_white(), feature_flags);
if let Some(os) = &report.os {
let os_line = [
os.name.clone().unwrap_or_else(|| "Unknown OS".to_string()),
os.version.clone().unwrap_or_default(),
]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
println!(" {} {}", "OS:".bright_white(), os_line);
if let Some(kernel) = &os.kernel_version {
println!(" {} {}", "Kernel:".bright_white(), kernel);
}
if let Some(arch) = &os.arch {
println!(" {} {}", "Arch:".bright_white(), arch);
}
}
if let Some(cpu) = &report.cpu {
println!(
" {} {} ({} cores, {:.1}% avg)",
"CPU:".bright_white(),
cpu.brand.clone().unwrap_or_else(|| "Unknown".into()),
cpu.cores,
cpu.usage
);
}
if let Some(shell) = &report.shell {
if let Some(s) = &shell.shell {
println!(" {} {}", "Shell:".bright_white(), s);
}
if let Some(t) = &shell.term {
println!(" {} {}", "TERM:".bright_white(), t);
}
}
println!(
" {} {}",
"Connectivity:".bright_white(),
if report.connectivity {
"online".green()
} else {
"offline".red()
}
);
if let Some(speed) = &report.internet_speed {
println!(" {} {:.2} ms", "Ping:".bright_white(), speed.ping_ms);
println!(
" {} {:.2} Mbps",
"Download:".bright_white(),
speed.download_mbps
);
if speed.upload_mbps > 0.0 {
println!(
" {} {:.2} Mbps",
"Upload:".bright_white(),
speed.upload_mbps
);
}
}
if !report.gpu_candidates.is_empty() {
print_section_header(
"GPU candidates",
SectionState::Info,
&format!("{} candidate(s)", report.gpu_candidates.len()),
);
for line in &report.gpu_candidates {
println!(" {}", line);
}
}
if !report.disks.is_empty() {
print_section_header(
"Disks",
SectionState::Info,
&format!("{} mount(s)", report.disks.len()),
);
for disk in &report.disks {
let current = if disk.is_current { " *" } else { "" };
let fs = disk.fs.clone().unwrap_or_else(|| "-".to_string());
println!(
" {}{} {} {:>5.1}% {} ({} / {} GB)",
disk.mount,
current,
fs,
disk.percent,
disk.bar,
disk.used / 1024 / 1024 / 1024,
disk.total / 1024 / 1024 / 1024
);
}
println!(" {} current mount", "*".bright_cyan());
}
print_section_header(
"System metrics",
SectionState::Info,
&format!("{} processes", report.system_metrics.process_count),
);
let metrics = &report.system_metrics;
let cpu_color = if metrics.cpu_usage > 80.0 {
"red"
} else if metrics.cpu_usage > 50.0 {
"yellow"
} else {
"green"
};
println!(
" {} {:.1}%",
"CPU usage:".bright_white(),
format!("{}", metrics.cpu_usage).color(cpu_color)
);
let mem_color = if metrics.memory_percent > 80.0 {
"red"
} else if metrics.memory_percent > 50.0 {
"yellow"
} else {
"green"
};
println!(
" {} {:.1}% ({} MB / {} MB)",
"Memory:".bright_white(),
format!("{}", metrics.memory_percent).color(mem_color),
metrics.memory_used / 1024 / 1024,
metrics.memory_total / 1024 / 1024
);
let disk_color = if metrics.disk_percent > 80.0 {
"red"
} else if metrics.disk_percent > 50.0 {
"yellow"
} else {
"green"
};
println!(
" {} {:.1}% ({} GB / {} GB)",
"Disk:".bright_white(),
format!("{}", metrics.disk_percent).color(disk_color),
metrics.disk_used / 1024 / 1024 / 1024,
metrics.disk_total / 1024 / 1024 / 1024
);
println!(
" {} {} MB down / {} MB up",
"Network:".bright_white(),
metrics.network_rx / 1024 / 1024,
metrics.network_tx / 1024 / 1024
);
let uptime_hours = metrics.uptime / 3600;
let uptime_minutes = (metrics.uptime % 3600) / 60;
println!(
" {} {}h {}m",
"Uptime:".bright_white(),
uptime_hours,
uptime_minutes
);
println!(
" {} {}",
"Processes:".bright_white(),
metrics.process_count
);
let (open_ports, blocked_ports, _) = summarize_port_checks(report);
let runtime_state = if blocked_ports == 0 {
SectionState::Healthy
} else {
SectionState::Fail
};
print_section_header(
"Runtime",
runtime_state,
&format!(
"services={} | pm2={} | ports={} open / {} blocked",
report.service_statuses.len(),
report.pm2_process_count.unwrap_or(0),
open_ports,
blocked_ports
),
);
if let Some(proxy) = &report.proxy_detection {
println!(" {}", "Proxy stack:".bright_white());
if let Some(n) = proxy.nginx_sites_available {
println!(" {} {}", "sites-available:".bright_white(), n);
}
if let Some(v) = proxy.nginx_active {
println!(" {} {}", "nginx active:".bright_white(), diag_bool(v));
}
if let Some(v) = proxy.traefik_active {
println!(" {} {}", "traefik active:".bright_white(), diag_bool(v));
}
if let Some(v) = proxy.apache_active {
println!(" {} {}", "apache active:".bright_white(), diag_bool(v));
}
}
if let Some(count) = report.pm2_process_count {
println!(" {} {}", "PM2 process count:".bright_white(), count);
}
if !report.service_statuses.is_empty() {
let mut items: Vec<_> = report
.service_statuses
.iter()
.filter(|(_, status)| status.as_str() != "unknown")
.collect();
if items.is_empty() {
println!(
" {} {}",
"Services:".bright_white(),
"(no systemd units detected on this host)".bright_black()
);
} else {
println!(" {}", "Services:".bright_white());
items.sort_by_key(|(k, _)| *k);
for (name, status) in items {
println!(" {:15} {}", name, color_service_status(status));
}
}
}
if let Some(nginx) = &report.nginx_status {
println!(" {}", "Nginx:".bright_white());
println!(
" {} {}",
if nginx.is_running {
"OK".green()
} else {
"NO".red()
},
if nginx.is_running {
"Running".green()
} else {
"Stopped".red()
}
);
println!(
" {} {}",
if nginx.is_enabled {
"OK".green()
} else {
"NO".red()
},
if nginx.is_enabled {
"Enabled".green()
} else {
"Disabled".red()
}
);
println!(
" {} {}",
if nginx.config_valid {
"OK".green()
} else {
"NO".red()
},
if nginx.config_valid {
"Config valid".green()
} else {
"Config invalid".red()
}
);
}
if !report.port_checks.is_empty() {
println!(" {}", "Port coverage:".bright_white());
let (xbp_ports, other_ports): (Vec<_>, Vec<_>) = report
.port_checks
.iter()
.partition(|port_check| !port_check.xbp_projects.is_empty());
for port_check in xbp_ports.iter().chain(other_ports.iter()) {
let status_icon = if port_check.is_open {
"OK".green()
} else {
"NO".red()
};
let blocked_text = if port_check.is_blocked {
" (BLOCKED)".red().to_string()
} else {
String::new()
};
let xbp_suffix = if port_check.xbp_projects.is_empty() {
String::new()
} else {
format!(" [XBP: {}]", port_check.xbp_projects.join(", "))
};
let line = format!(
" {} Port {}: {}{}{}",
status_icon,
port_check.port,
if port_check.is_open {
"Available".green().to_string()
} else {
"In use".red().to_string()
},
blocked_text,
xbp_suffix
);
if port_check.xbp_projects.is_empty() {
println!("{}", line);
} else {
println!("{}", line.bright_magenta());
}
}
}
let (path_passing, path_total) = summarize_path_permissions(report);
let project_state = if report.project_config_present || !report.provider_manifests.is_empty() {
if path_passing == path_total {
SectionState::Healthy
} else {
SectionState::Warn
}
} else {
SectionState::Info
};
print_section_header(
"Project",
project_state,
&format!(
"config={} | services={} | manifests={} | paths={}",
diag_bool(report.project_config_present),
report.configured_services.len(),
report.provider_manifests.len(),
diag_ratio(path_passing, path_total)
),
);
if !report.configured_services.is_empty() {
println!(
" {} {}",
"Configured services:".bright_white(),
report.configured_services.join(", ").bright_white()
);
}
if !report.provider_manifests.is_empty() {
println!(
" {} {}",
"Provider manifests:".bright_white(),
report.provider_manifests.join(", ").bright_white()
);
}
if !report.path_permission_checks.is_empty() {
println!(
" {} {}",
"Checks passing:".bright_white(),
diag_ratio(path_passing, path_total)
);
for item in &report.path_permission_checks {
let icon = if item.ok { "OK".green() } else { "NO".red() };
println!(
" {} {} [{}]",
icon,
item.path.bright_white(),
item.required
);
println!(" purpose: {}", item.purpose);
println!(
" status: {} {} {} {}",
diag_bool_fragment("exists", item.exists),
diag_bool_fragment("read", item.readable),
diag_bool_fragment("write", item.writable),
diag_bool_fragment("create", item.creatable)
);
if let Some(msg) = &item.message {
println!(" note: {}", msg);
}
}
}
print_section_header(
"XBP readiness",
if report.project_config_present {
SectionState::Healthy
} else {
SectionState::Warn
},
&format!(
"version={} | feature flags={} | manifests={}",
report.xbp_cli_version,
report.feature_flags.len(),
report.provider_manifests.len()
),
);
println!(
" {} {}",
"CLI version:".bright_white(),
report.xbp_cli_version.bright_cyan()
);
println!(" {} {}", "Feature flags:".bright_white(), feature_flags);
println!(
" {} {}",
"Project config:".bright_white(),
if report.project_config_present {
"detected".green()
} else {
"missing".red()
}
);
println!(
" {} {}",
"Provider manifests:".bright_white(),
report.provider_manifests.len()
);
if !report.dns_checks.is_empty() {
let fail = report.dns_checks.iter().filter(|d| !d.ok).count();
print_section_header(
"DNS",
if fail == 0 {
SectionState::Healthy
} else {
SectionState::Warn
},
&format!("{} host(s), {} failed", report.dns_checks.len(), fail),
);
for dns in &report.dns_checks {
let icon = if dns.ok { "OK".green() } else { "NO".red() };
println!(
" {} {} → {}",
icon,
dns.host.bright_white(),
if dns.ok {
dns.addresses.join(", ")
} else {
dns.error.clone().unwrap_or_else(|| "lookup failed".into())
}
);
}
}
if !report.http_probes.is_empty() {
let ok = report.http_probes.iter().filter(|p| p.ok).count();
print_section_header(
"HTTP probes",
if ok == report.http_probes.len() {
SectionState::Healthy
} else if ok == 0 {
SectionState::Fail
} else {
SectionState::Warn
},
&format!("{ok}/{} responding", report.http_probes.len()),
);
for probe in &report.http_probes {
let icon = if probe.ok { "OK".green() } else { "NO".red() };
let status = probe
.status
.map(|s| s.to_string())
.unwrap_or_else(|| "-".into());
let latency = probe
.latency_ms
.map(|ms| format!("{ms}ms"))
.unwrap_or_else(|| "-".into());
println!(
" {} {:16} {} status={} latency={}",
icon,
probe.name.bright_white(),
probe.url.bright_black(),
status,
latency
);
if let Some(err) = &probe.error {
println!(" {}", err.yellow());
}
}
}
if let Some(git) = &report.git {
print_section_header(
"Git",
if !git.is_repo {
SectionState::Info
} else if git.dirty == Some(true) {
SectionState::Warn
} else {
SectionState::Healthy
},
if git.is_repo {
git.branch.as_deref().unwrap_or("detached")
} else {
"not a repo"
},
);
if git.is_repo {
if let Some(branch) = &git.branch {
println!(" {} {}", "Branch:".bright_white(), branch);
}
if let Some(dirty) = git.dirty {
println!(
" {} {}",
"Dirty:".bright_white(),
if dirty {
"yes".yellow()
} else {
"no".green()
}
);
}
if let Some(remote) = &git.remote {
println!(" {} {}", "Origin:".bright_white(), remote);
}
for note in &git.notes {
println!(" {} {}", "Note:".bright_white(), note.yellow());
}
}
}
if let Some(auth) = &report.cli_auth {
print_section_header(
"CLI auth",
if auth.logged_in {
SectionState::Healthy
} else {
SectionState::Warn
},
if auth.logged_in { "signed in" } else { "signed out" },
);
println!(" {} {}", "Status:".bright_white(), auth.message);
if let Some(email) = &auth.user_email {
println!(
" {} {} ({})",
"User:".bright_white(),
email,
auth.user_name.clone().unwrap_or_default()
);
}
if let Some(prefix) = &auth.token_prefix {
println!(" {} {}", "Token:".bright_white(), prefix);
}
}
if let Some(docker) = &report.docker {
print_section_header(
"Docker",
if !docker.cli_present {
SectionState::Info
} else if docker.daemon_ok {
SectionState::Healthy
} else {
SectionState::Warn
},
if docker.daemon_ok {
"daemon ok"
} else if docker.cli_present {
"daemon down"
} else {
"not installed"
},
);
println!(
" {} cli={} daemon={} compose={}",
"Status:".bright_white(),
diag_bool(docker.cli_present),
diag_bool(docker.daemon_ok),
diag_bool(docker.compose_present)
);
if let Some(v) = &docker.version {
println!(" {} {}", "Version:".bright_white(), v);
}
if let Some(note) = &docker.note {
println!(" {} {}", "Note:".bright_white(), note.yellow());
}
}
if !report.env_files.is_empty() {
print_section_header(
"Env files",
SectionState::Info,
&format!("{} found", report.env_files.len()),
);
for f in &report.env_files {
println!(" {}", f.bright_cyan());
}
}
if !report.network_interfaces.is_empty() {
print_section_header(
"Network interfaces",
SectionState::Info,
&format!("{} iface(s)", report.network_interfaces.len()),
);
for iface in report.network_interfaces.iter().take(12) {
println!(
" {:16} rx={} MB tx={} MB",
iface.name,
iface.rx_bytes / 1024 / 1024,
iface.tx_bytes / 1024 / 1024
);
}
}
if !report.tool_versions.is_empty() {
let present = report
.tool_versions
.values()
.filter(|t| t.present)
.count();
print_section_header(
"Tool versions",
SectionState::Info,
&format!("{present}/{} present", report.tool_versions.len()),
);
let mut items: Vec<_> = report.tool_versions.iter().collect();
items.sort_by_key(|(k, _)| *k);
for (name, tool) in items {
if !tool.present {
continue;
}
println!(
" {:14} {}",
name.bright_white(),
tool.version.clone().unwrap_or_else(|| "present".into())
);
}
}
if !report.installed_programs.is_empty() {
let mut missing: Vec<_> = report
.installed_programs
.iter()
.filter(|(_, ok)| !**ok)
.map(|(k, _)| k.clone())
.collect();
missing.sort();
let present = report.installed_programs.len() - missing.len();
print_section_header(
"Toolchain PATH",
if missing.len() > report.installed_programs.len() / 2 {
SectionState::Warn
} else {
SectionState::Info
},
&format!("{present} present / {} missing", missing.len()),
);
if !missing.is_empty() {
println!(
" {} {}",
"Missing:".bright_white(),
missing.join(", ").yellow()
);
}
}
if !report.recommendations.is_empty() {
print_section_header(
"Recommendations",
SectionState::Info,
&format!("{} tip(s)", report.recommendations.len()),
);
for (i, rec) in report.recommendations.iter().enumerate() {
println!(" {}. {}", i + 1, rec);
}
}
if let Some(watch) = &report.worktree_watch {
let watch_state = if watch.branch_spool_exists || watch.watcher_running {
SectionState::Healthy
} else if watch.notes.iter().any(|note| note.contains("Failed")) {
SectionState::Warn
} else {
SectionState::Info
};
let summary = match (
watch.repo_owner.as_deref(),
watch.repo_name.as_deref(),
watch.branch.as_deref(),
) {
(Some(owner), Some(name), Some(branch)) => format!(
"runtime={} | {}/{} @ {} | events={} commits={}",
watch.runtime_key, owner, name, branch, watch.event_jsonl_files, watch.commit_jsonl_files
),
_ => format!(
"runtime={} | global home only | wsl={}",
watch.runtime_key,
diag_bool(watch.is_wsl)
),
};
print_section_header("Worktree watch", watch_state, &summary);
println!(
" {} {}",
"Global XBP home:".bright_white(),
watch.global_xbp_root.bright_cyan()
);
println!(
" {} {}",
"Mutations root:".bright_white(),
watch.mutations_root
);
println!(
" {} {} | {} {}",
"Platform:".bright_white(),
watch.platform,
"runtime:".bright_white(),
watch.runtime_key.bright_cyan()
);
println!(
" {} {}",
"WSL:".bright_white(),
diag_bool(watch.is_wsl)
);
if let (Some(owner), Some(name), Some(branch)) = (
watch.repo_owner.as_deref(),
watch.repo_name.as_deref(),
watch.branch.as_deref(),
) {
println!(
" {} {}/{} ({})",
"Repository:".bright_white(),
owner,
name,
branch
);
}
if let Some(spool) = &watch.branch_spool {
println!(
" {} {} {}",
"Branch spool:".bright_white(),
spool,
if watch.branch_spool_exists {
"(exists)".green()
} else {
"(missing)".yellow()
}
);
}
println!(
" {} events={} commits={}",
"Spool files:".bright_white(),
watch.event_jsonl_files,
watch.commit_jsonl_files
);
if let Some(state_path) = &watch.watcher_state_path {
println!(
" {} {} | present={} running={}",
"Watcher state:".bright_white(),
state_path,
diag_bool(watch.watcher_state_present),
diag_bool(watch.watcher_running)
);
}
if !watch.alternate_spool_roots.is_empty() {
println!(" {}", "Alternate spools (legacy / shared):".bright_white());
for root in &watch.alternate_spool_roots {
println!(" {}", root);
}
}
if !watch.notes.is_empty() {
println!(" {}", "Notes:".bright_white());
for note in &watch.notes {
println!(" - {}", note);
}
}
}
if let Some(codetime) = &report.codetime {
print_section_header(
"CodeTime inventory",
if codetime.refreshed {
SectionState::Healthy
} else {
SectionState::Info
},
&format!(
"saved={} | refreshed={}",
codetime.saved_config_path,
diag_bool(codetime.refreshed)
),
);
println!(
" {} {}",
"Saved in:".bright_white(),
codetime.saved_config_path
);
println!(
" {} {}",
"Refreshed:".bright_white(),
if codetime.refreshed {
"cached"
} else {
"fresh"
}
);
if let Some(user) = &codetime.inventory.system_user {
println!(" {} {}", "User:".bright_white(), user);
}
if let Some(host) = &codetime.inventory.host_name {
println!(" {} {}", "Host:".bright_white(), host);
}
println!(
" {} {} / {}",
"Platform:".bright_white(),
codetime.inventory.platform,
codetime.inventory.architecture
);
if let Some(os) = &codetime.inventory.operating_system {
let os_label = os
.long_version
.clone()
.or_else(|| match (&os.name, &os.version) {
(Some(name), Some(version)) => Some(format!("{} {}", name, version)),
(Some(name), None) => Some(name.clone()),
(None, Some(version)) => Some(version.clone()),
(None, None) => None,
});
if let Some(os_label) = os_label {
println!(" {} {}", "Operating system:".bright_white(), os_label);
}
if let Some(kernel_version) = &os.kernel_version {
println!(" {} {}", "Kernel:".bright_white(), kernel_version);
}
}
if !codetime.inventory.drives.is_empty() {
let drives = codetime
.inventory
.drives
.iter()
.map(|drive| match &drive.file_system {
Some(file_system) => format!("{} ({})", drive.mount, file_system),
None => drive.mount.clone(),
})
.collect::<Vec<_>>()
.join(", ");
println!(" {} {}", "Drives:".bright_white(), drives);
}
if let Some(home) = codetime.inventory.standard_paths.get("home") {
println!(" {} {}", "Home:".bright_white(), home);
}
if let Some(xbp_root) = codetime.inventory.standard_paths.get("xbp_global_root") {
println!(" {} {}", "XBP root:".bright_white(), xbp_root);
}
if let Some(cursor_root) = codetime.inventory.standard_paths.get("cursor_roaming") {
println!(" {} {}", "Cursor root:".bright_white(), cursor_root);
}
let installed_tool_labels = codetime
.inventory
.installed_tools
.iter()
.filter(|tool| tool.present)
.map(|tool| match &tool.version {
Some(version) => format!("{} ({})", tool.name, version),
None => tool.name.clone(),
})
.collect::<Vec<_>>();
println!(
" {} {}",
"Installed tools:".bright_white(),
if installed_tool_labels.is_empty() {
"0".to_string()
} else {
format!(
"{} ({})",
installed_tool_labels.len(),
format_sample_list(&installed_tool_labels, 8)
)
}
);
let identity_labels = codetime
.inventory
.git_identities
.iter()
.map(
|identity| match (&identity.user_name, &identity.user_email) {
(Some(name), Some(email)) => format!("{} <{}>", name, email),
(Some(name), None) => name.clone(),
(None, Some(email)) => email.clone(),
(None, None) => identity.scope.clone(),
},
)
.collect::<Vec<_>>();
if !identity_labels.is_empty() {
println!(
" {} {}",
"Git identities:".bright_white(),
format_sample_list(&identity_labels, 4)
);
}
let repo_names = codetime
.inventory
.github_repos
.iter()
.map(|repo| repo.full_name.clone())
.collect::<Vec<_>>();
println!(
" {} {}",
"Seen GitHub repos:".bright_white(),
if repo_names.is_empty() {
"0".to_string()
} else {
format!(
"{} ({})",
repo_names.len(),
format_sample_list(&repo_names, 5)
)
}
);
let project_names = codetime
.inventory
.xbp_projects
.iter()
.map(|project| project.name.clone())
.collect::<Vec<_>>();
println!(
" {} {}",
"Seen XBP projects:".bright_white(),
if project_names.is_empty() {
"0".to_string()
} else {
format!(
"{} ({})",
project_names.len(),
format_sample_list(&project_names, 5)
)
}
);
if let Some(cursor) = &codetime.inventory.cursor {
println!(
" {} {}",
"Cursor present:".bright_white(),
diag_bool(cursor.exists)
);
println!(
" {} workspaceStorage={} extensions={} logs={}",
"Cursor details:".bright_white(),
cursor.workspace_storage_entries,
cursor.extensions_entries,
diag_bool(cursor.logs_exists)
);
if let Some(note) = &cursor.note {
println!(" {} {}", "Cursor note:".bright_white(), note);
}
}
}
if !report.clipboard_tools.is_empty() {
println!("\n{}", "Clipboard tools".bright_yellow().bold());
let mut items: Vec<_> = report.clipboard_tools.iter().collect();
items.sort_by_key(|(k, _)| *k);
for (name, value) in items {
println!(" {:10} {}", name, diag_bool(*value));
}
}
if !report.installed_programs.is_empty() {
print_section_header(
"Installed programs",
SectionState::Info,
&format!("{} program(s)", report.installed_programs.len()),
);
let mut programs: Vec<_> = report.installed_programs.iter().collect();
programs.sort_by_key(|(name, _)| *name);
for (program, installed) in programs {
let status_icon = if *installed { "OK".green() } else { "NO".red() };
let status_text = if *installed {
"Installed".green()
} else {
"Missing".red()
};
println!(" {} {:15} {}", status_icon, program, status_text);
}
}
if !report.tool_versions.is_empty() {
print_section_header(
"Tool versions",
SectionState::Info,
&format!("{} tool(s)", report.tool_versions.len()),
);
let mut items: Vec<_> = report.tool_versions.iter().collect();
items.sort_by_key(|(k, _)| *k);
for (name, v) in items {
if v.present {
println!(
" {:12} {}",
name,
v.version
.clone()
.unwrap_or_else(|| "present".to_string())
.green()
);
} else {
println!(" {:12} {}", name, "not found".red());
}
}
}
}
fn format_sample_list(items: &[String], limit: usize) -> String {
if items.is_empty() {
return String::new();
}
let shown = items.iter().take(limit).cloned().collect::<Vec<_>>();
let remaining = items.len().saturating_sub(shown.len());
if remaining == 0 {
shown.join(", ")
} else {
format!("{} +{} more", shown.join(", "), remaining)
}
}
fn format_eta(duration: Duration) -> String {
let total_seconds = duration.as_secs();
let minutes = total_seconds / 60;
let seconds = total_seconds % 60;
if minutes > 0 {
format!("{}m {:02}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
fn estimate_diag_timeout_budget(
ports_count: usize,
skip_speed_test: bool,
include_codetime: bool,
) -> Duration {
let fixed_seconds =
2 + 2 + 2 + 2 + 3 + 3 + 10 + 4 + PM2_DIAG_TIMEOUT.as_secs() + NGINX_CHECK_TIMEOUT.as_secs();
let port_seconds =
PORT_OWNERSHIP_TIMEOUT.as_secs() + (ports_count as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
let speed_seconds = if skip_speed_test {
0
} else {
INTERNET_SPEED_TIMEOUT.as_secs()
};
let codetime_seconds = if include_codetime {
SYSTEM_INVENTORY_TIMEOUT.as_secs()
} else {
0
};
Duration::from_secs(fixed_seconds + port_seconds + speed_seconds + codetime_seconds)
}
fn set_step_message(
pb: &ProgressBar,
step_index: usize,
step_total: usize,
started_at: Instant,
estimated_budget: Duration,
label: &str,
timeout: Option<Duration>,
) {
let elapsed = started_at.elapsed();
let remaining = estimated_budget.saturating_sub(elapsed);
let timeout_suffix = timeout
.map(|value| format!(" | timeout {}s", value.as_secs()))
.unwrap_or_default();
pb.set_message(format!(
"[{}/{}] {} | ETA {}{}",
step_index,
step_total,
label,
format_eta(remaining),
timeout_suffix
));
}
async fn collect_port_ownership_with_timeout() -> BTreeMap<u16, ListeningPortOwnership> {
let task = tokio::task::spawn_blocking(collect_listening_port_ownership);
match tokio::time::timeout(PORT_OWNERSHIP_TIMEOUT, task).await {
Ok(Ok(Ok(ports))) => ports,
Ok(Ok(Err(err))) => {
let _ = log_warn("diag", "Port ownership scan failed", Some(err.as_str())).await;
BTreeMap::new()
}
Ok(Err(err)) => {
let message = format!("Port ownership scan task failed: {}", err);
let _ = log_warn("diag", "Port ownership scan failed", Some(message.as_str())).await;
BTreeMap::new()
}
Err(_) => {
let timeout_note = format!(
"Port ownership scan timed out after {} seconds",
PORT_OWNERSHIP_TIMEOUT.as_secs()
);
let _ = log_warn(
"diag",
"Port ownership scan timed out",
Some(timeout_note.as_str()),
)
.await;
BTreeMap::new()
}
}
}
async fn sync_system_inventory_with_timeout(include_cursor: bool) -> Option<CodeTimeDiagnostic> {
let current_dir = env::current_dir().ok();
let task = tokio::task::spawn_blocking(move || {
sync_system_inventory(true, include_cursor, current_dir.as_deref())
});
match tokio::time::timeout(SYSTEM_INVENTORY_TIMEOUT, task).await {
Ok(Ok(Ok(result))) => Some(CodeTimeDiagnostic {
saved_config_path: result.config_path.display().to_string(),
refreshed: result.refreshed,
inventory: result.inventory,
}),
Ok(Ok(Err(err))) => {
let _ = log_warn("diag", "System inventory sync failed", Some(err.as_str())).await;
None
}
Ok(Err(err)) => {
let message = format!("System inventory task failed: {}", err);
let _ = log_warn(
"diag",
"System inventory sync failed",
Some(message.as_str()),
)
.await;
None
}
Err(_) => {
let message = format!(
"System inventory sync timed out after {} seconds",
SYSTEM_INVENTORY_TIMEOUT.as_secs()
);
let _ = log_warn(
"diag",
"System inventory sync timed out",
Some(message.as_str()),
)
.await;
None
}
}
}
pub async fn run_full_diagnostics(
ports: Vec<u16>,
options: DiagnosticRunOptions,
) -> Result<DiagnosticReport> {
let _ = log_info("diag", "Running system diagnostics...", None).await;
let skip_speed = options.skip_speed_test || options.quick;
let do_http = (options.http_probe || options.full) && !options.quick;
let do_deep = options.full && !options.quick;
let estimated_budget =
estimate_diag_timeout_budget(ports.len(), skip_speed, options.codetime);
let timeout_summary = format!(
"Estimated max runtime {} (PM2 {}s, ports {}s + {}s/port{}{})",
format_eta(estimated_budget),
PM2_DIAG_TIMEOUT.as_secs(),
PORT_OWNERSHIP_TIMEOUT.as_secs(),
PER_PORT_CHECK_TIMEOUT.as_secs(),
if skip_speed {
", speed test skipped"
} else {
", speed test up to 20s"
},
if options.codetime {
", codetime inventory up to 25s"
} else {
""
}
);
let _ = log_info(
"diag",
"Diagnostics timeout budget",
Some(timeout_summary.as_str()),
)
.await;
let pb: ProgressBar = ProgressBar::new_spinner();
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_style(
ProgressStyle::with_template("{spinner} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
let started_at = Instant::now();
let mut step_total = if options.codetime { 14 } else { 13 };
if do_deep {
step_total += 4;
} else if do_http {
step_total += 1;
}
let mut step_index = 1usize;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Collecting system metrics",
None,
);
step_index += 1;
let system_metrics = get_system_metrics().await?;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Collecting OS/CPU/GPU",
None,
);
step_index += 1;
let os = get_os_info().await;
let cpu = get_cpu_info().await;
let gpu_candidates = get_gpu_candidates().await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Collecting disks",
None,
);
step_index += 1;
let disks = get_disk_infos().await;
let codetime = if options.codetime {
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
if options.cursor {
"Refreshing CodeTime + Cursor inventory"
} else {
"Refreshing CodeTime inventory"
},
Some(SYSTEM_INVENTORY_TIMEOUT),
);
step_index += 1;
sync_system_inventory_with_timeout(options.cursor).await
} else {
None
};
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Collecting shell + clipboard",
None,
);
step_index += 1;
let shell = get_shell_info();
let clipboard_tools = get_clipboard_tools().await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Detecting proxies",
None,
);
step_index += 1;
let proxy_detection = get_proxy_detection().await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking network/public IP",
None,
);
step_index += 1;
let connectivity = check_internet_connectivity().await.unwrap_or(false);
let exposure = get_exposure_info(connectivity).await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking tools",
Some(TOOL_VERSION_TIMEOUT),
);
step_index += 1;
let installed_programs = check_installed_programs().await;
let tool_versions = get_tool_versions().await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking file permissions",
None,
);
step_index += 1;
let path_permission_checks = get_path_permission_checks().await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking services",
None,
);
step_index += 1;
let service_statuses = get_service_statuses().await;
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking PM2 process table",
Some(PM2_DIAG_TIMEOUT),
);
step_index += 1;
let pm2_process_count = get_pm2_process_count().await;
let ports_timeout_budget = PORT_OWNERSHIP_TIMEOUT
+ Duration::from_secs(ports.len() as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking ports",
Some(ports_timeout_budget),
);
step_index += 1;
let port_ownership = collect_port_ownership_with_timeout().await;
let mut port_checks = Vec::new();
for port in ports {
let timed_check = tokio::time::timeout(
PER_PORT_CHECK_TIMEOUT,
check_port_availability(port, port_ownership.get(&port)),
)
.await;
match timed_check {
Ok(Ok(check)) => port_checks.push(check),
Ok(Err(err)) => {
let details = format!("Port {} check failed: {}", port, err);
let _ = log_warn("diag", "Port check failed", Some(details.as_str())).await;
}
Err(_) => {
let details = format!(
"Port {} check timed out after {} seconds",
port,
PER_PORT_CHECK_TIMEOUT.as_secs()
);
let _ = log_warn("diag", "Port check timed out", Some(details.as_str())).await;
}
}
}
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Measuring internet speed",
Some(INTERNET_SPEED_TIMEOUT),
);
step_index += 1;
let internet_speed = if skip_speed {
let _ = log_info(
"diag",
"Skipping internet speed test (--no-speed-test or --quick)",
None,
)
.await;
None
} else if connectivity {
match tokio::time::timeout(INTERNET_SPEED_TIMEOUT, measure_internet_speed()).await {
Ok(Ok(speed)) => Some(speed),
Ok(Err(err)) => {
let details = format!("Internet speed probe failed: {}", err);
let _ = log_warn(
"diag",
"Internet speed probe failed",
Some(details.as_str()),
)
.await;
None
}
Err(_) => {
let details = format!(
"Internet speed probe timed out after {} seconds",
INTERNET_SPEED_TIMEOUT.as_secs()
);
let _ = log_warn(
"diag",
"Internet speed probe timed out",
Some(details.as_str()),
)
.await;
None
}
}
} else {
None
};
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking nginx",
Some(NGINX_CHECK_TIMEOUT),
);
let nginx_status = match tokio::time::timeout(NGINX_CHECK_TIMEOUT, check_nginx_status()).await {
Ok(Ok(status)) => Some(status),
Ok(Err(err)) => {
let details = format!("Nginx status check failed: {}", err);
let _ = log_warn("diag", "Nginx status check failed", Some(details.as_str())).await;
None
}
Err(_) => {
let details = format!(
"Nginx status check timed out after {} seconds",
NGINX_CHECK_TIMEOUT.as_secs()
);
let _ = log_warn(
"diag",
"Nginx status check timed out",
Some(details.as_str()),
)
.await;
None
}
};
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let config = load_xbp_config().await.ok();
let project_config_present = config.is_some();
let service_rows: Vec<(String, u16, Option<String>)> = config
.as_ref()
.map(|cfg| {
get_all_services(cfg)
.into_iter()
.map(|service| (service.name.clone(), service.port, service.url.clone()))
.collect()
})
.unwrap_or_default();
let configured_services = service_rows
.iter()
.map(|(name, port, _)| format!("{name}:{port}"))
.collect::<Vec<_>>();
let provider_manifests: Vec<String> = ProjectDetector::detect_provider_manifests(¤t_dir);
let env_files = discover_env_files(¤t_dir);
let hostname = System::host_name();
let local_ips = collect_local_ips().await;
let network_interfaces = collect_network_interfaces();
let dns_checks = if options.quick {
Vec::new()
} else {
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Resolving DNS",
None,
);
step_index += 1;
let mut hosts = vec!["github.com", "crates.io", "registry.npmjs.org", "1.1.1.1"];
if do_deep {
hosts.extend(["docs.xbp.app", "api.xbp.app", "xbp.app"]);
}
collect_dns_checks(&hosts).await
};
let http_probes = if do_http && !service_rows.is_empty() {
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Probing service HTTP endpoints",
None,
);
step_index += 1;
collect_http_probes(&service_rows).await
} else {
Vec::new()
};
let git = if do_deep || !options.quick {
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking git hygiene",
None,
);
step_index += 1;
collect_git_hygiene(¤t_dir).await
} else {
None
};
let cli_auth = if do_deep {
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking CLI session",
None,
);
step_index += 1;
Some(collect_cli_auth_diagnostic().await)
} else {
None
};
let docker = if do_deep || cfg!(feature = "docker") {
set_step_message(
&pb,
step_index,
step_total,
started_at,
estimated_budget,
"Checking Docker",
None,
);
let _ = step_index;
Some(collect_docker_diagnostic().await)
} else {
None
};
let mut feature_flags: HashMap<String, bool> = HashMap::new();
feature_flags.insert("monitoring".to_string(), cfg!(feature = "monitoring"));
feature_flags.insert(
"kafka".to_string(),
cfg!(all(feature = "kafka", not(windows))),
);
feature_flags.insert("kubernetes".to_string(), cfg!(feature = "kubernetes"));
feature_flags.insert("docker".to_string(), cfg!(feature = "docker"));
feature_flags.insert("linear".to_string(), cfg!(feature = "linear"));
feature_flags.insert("secrets".to_string(), cfg!(feature = "secrets"));
feature_flags.insert("athena".to_string(), cfg!(feature = "athena"));
feature_flags.insert("nordvpn".to_string(), cfg!(feature = "nordvpn"));
feature_flags.insert("openapi-gen".to_string(), cfg!(feature = "openapi-gen"));
let xbp_cli_version = env!("CARGO_PKG_VERSION").to_string();
let worktree_watch = Some(collect_worktree_watch_diagnostic());
pb.finish_and_clear();
let mut report = DiagnosticReport {
system_metrics,
os,
cpu,
gpu_candidates,
disks,
shell,
proxy_detection,
clipboard_tools,
exposure,
pm2_process_count,
tool_versions,
service_statuses,
feature_flags,
xbp_cli_version,
hostname,
cwd: current_dir.display().to_string(),
local_ips,
network_interfaces,
project_config_present,
configured_services,
provider_manifests,
env_files,
nginx_status,
port_checks,
internet_speed,
connectivity,
dns_checks,
http_probes,
git,
cli_auth,
docker,
installed_programs,
path_permission_checks,
codetime,
worktree_watch,
recommendations: Vec::new(),
scorecard: DiagScorecard {
healthy: 0,
warnings: 0,
failures: 0,
grade: "OK".into(),
},
};
report.recommendations = build_recommendations(&report);
report.scorecard = build_scorecard(&report);
Ok(report)
}
pub fn print_diagnostic_report_json(report: &DiagnosticReport) -> Result<()> {
let json = serde_json::to_string_pretty(report)?;
println!("{json}");
Ok(())
}
#[cfg(test)]
mod expanded_diag_tests {
use super::*;
fn minimal_report() -> DiagnosticReport {
DiagnosticReport {
system_metrics: SystemMetrics {
cpu_usage: 10.0,
memory_total: 1000,
memory_used: 100,
memory_percent: 10.0,
disk_total: 1000,
disk_used: 100,
disk_percent: 10.0,
network_rx: 0,
network_tx: 0,
uptime: 100,
process_count: 1,
},
os: None,
cpu: None,
gpu_candidates: Vec::new(),
disks: Vec::new(),
shell: None,
proxy_detection: None,
clipboard_tools: HashMap::new(),
exposure: None,
pm2_process_count: Some(0),
tool_versions: HashMap::new(),
service_statuses: HashMap::new(),
feature_flags: HashMap::new(),
xbp_cli_version: "0.0.0".into(),
hostname: Some("test".into()),
cwd: "/tmp".into(),
local_ips: vec!["127.0.0.1".into()],
network_interfaces: Vec::new(),
project_config_present: true,
configured_services: Vec::new(),
provider_manifests: Vec::new(),
env_files: Vec::new(),
nginx_status: None,
port_checks: Vec::new(),
internet_speed: None,
connectivity: true,
dns_checks: Vec::new(),
http_probes: Vec::new(),
git: None,
cli_auth: None,
docker: None,
installed_programs: HashMap::new(),
path_permission_checks: Vec::new(),
codetime: None,
worktree_watch: None,
recommendations: Vec::new(),
scorecard: DiagScorecard {
healthy: 0,
warnings: 0,
failures: 0,
grade: "OK".into(),
},
}
}
#[test]
fn scorecard_is_ok_when_healthy() {
let report = minimal_report();
let card = build_scorecard(&report);
assert_eq!(card.grade, "OK");
assert!(card.failures == 0);
}
#[test]
fn scorecard_fails_when_offline() {
let mut report = minimal_report();
report.connectivity = false;
let card = build_scorecard(&report);
assert_eq!(card.grade, "FAIL");
assert!(card.failures > 0);
}
#[test]
fn recommendations_suggest_login_when_signed_out() {
let mut report = minimal_report();
report.cli_auth = Some(CliAuthDiagnostic {
logged_in: false,
user_email: None,
user_name: None,
token_prefix: None,
message: "not logged in".into(),
});
let recs = build_recommendations(&report);
assert!(recs.iter().any(|r| r.contains("xbp login")));
}
#[test]
fn recommendations_empty_critical_path_gives_ready_message() {
let mut report = minimal_report();
report.installed_programs.insert("git".into(), true);
let recs = build_recommendations(&report);
assert!(
recs.iter().any(|r| r.contains("ready") || r.contains("No critical")),
"got {recs:?}"
);
}
#[test]
fn scorecard_warns_without_project_config() {
let mut report = minimal_report();
report.project_config_present = false;
let card = build_scorecard(&report);
assert!(card.warnings > 0 || card.grade == "WARN" || card.grade == "OK");
assert_ne!(card.grade, "FAIL");
}
#[test]
fn scorecard_fails_on_blocked_ports() {
let mut report = minimal_report();
report.port_checks.push(PortCheck {
port: 80,
is_open: false,
is_blocked: true,
pids: Vec::new(),
xbp_projects: Vec::new(),
});
let card = build_scorecard(&report);
assert_eq!(card.grade, "FAIL");
}
#[test]
fn scorecard_warns_on_high_disk() {
let mut report = minimal_report();
report.system_metrics.disk_percent = 85.0;
let card = build_scorecard(&report);
assert!(card.warnings > 0 || card.grade == "WARN");
}
#[test]
fn scorecard_fails_on_critical_disk() {
let mut report = minimal_report();
report.system_metrics.disk_percent = 95.0;
let card = build_scorecard(&report);
assert_eq!(card.grade, "FAIL");
}
#[test]
fn scorecard_http_partial_is_warn() {
let mut report = minimal_report();
report.http_probes = vec![
HttpProbe {
name: "a".into(),
url: "http://a".into(),
ok: true,
status: Some(200),
latency_ms: Some(1),
error: None,
},
HttpProbe {
name: "b".into(),
url: "http://b".into(),
ok: false,
status: None,
latency_ms: None,
error: Some("down".into()),
},
];
let card = build_scorecard(&report);
assert!(matches!(card.grade.as_str(), "WARN" | "FAIL" | "OK"));
assert!(card.warnings + card.failures >= 1 || card.healthy >= 1);
}
#[test]
fn recommendations_disk_memory_and_docker() {
let mut report = minimal_report();
report.installed_programs.insert("git".into(), true);
report.system_metrics.disk_percent = 90.0;
report.system_metrics.memory_percent = 95.0;
report.docker = Some(DockerDiagnostic {
cli_present: true,
daemon_ok: false,
version: Some("24".into()),
compose_present: true,
note: Some("down".into()),
});
report.disks.push(DiskInfo {
mount: "/".into(),
fs: Some("ext4".into()),
total: 100,
used: 90,
percent: 90.0,
bar: String::new(),
is_current: true,
});
let recs = build_recommendations(&report);
assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("disk")));
assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("memory")));
assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("docker")));
}
#[test]
fn recommendations_blocked_ports_and_http_and_dns() {
let mut report = minimal_report();
report.installed_programs.insert("git".into(), true);
report.port_checks.push(PortCheck {
port: 443,
is_open: false,
is_blocked: true,
pids: Vec::new(),
xbp_projects: Vec::new(),
});
report.http_probes.push(HttpProbe {
name: "api".into(),
url: "http://127.0.0.1:1/".into(),
ok: false,
status: None,
latency_ms: None,
error: Some("nope".into()),
});
report.dns_checks.push(DnsCheck {
host: "example.invalid".into(),
ok: false,
addresses: Vec::new(),
error: Some("nxdomain".into()),
});
let recs = build_recommendations(&report);
assert!(recs.iter().any(|r| r.contains("firewall") || r.contains("port")));
assert!(recs.iter().any(|r| r.contains("HTTP") || r.contains("api")));
assert!(recs.iter().any(|r| r.contains("DNS") || r.contains("example.invalid")));
}
#[test]
fn recommendations_nginx_invalid_and_dirty_git() {
let mut report = minimal_report();
report.installed_programs.insert("git".into(), true);
report.nginx_status = Some(NginxStatus {
is_running: true,
is_enabled: true,
config_valid: false,
error: None,
});
report.git = Some(GitHygiene {
is_repo: true,
branch: Some("main".into()),
dirty: Some(true),
ahead: None,
behind: None,
remote: None,
notes: vec!["dirty".into()],
});
let recs = build_recommendations(&report);
assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("nginx")));
assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("git")));
}
#[test]
fn recommendations_missing_git() {
let mut report = minimal_report();
report.installed_programs.insert("git".into(), false);
let recs = build_recommendations(&report);
assert!(recs.iter().any(|r| r.contains("git")));
}
#[test]
fn summarize_port_checks_counts() {
let mut report = minimal_report();
report.port_checks = vec![
PortCheck {
port: 1,
is_open: true,
is_blocked: false,
pids: Vec::new(),
xbp_projects: Vec::new(),
},
PortCheck {
port: 2,
is_open: false,
is_blocked: true,
pids: Vec::new(),
xbp_projects: Vec::new(),
},
PortCheck {
port: 3,
is_open: true,
is_blocked: false,
pids: Vec::new(),
xbp_projects: Vec::new(),
},
];
let (open, blocked, total) = super::summarize_port_checks(&report);
assert_eq!(open, 2);
assert_eq!(blocked, 1);
assert_eq!(total, 3);
}
#[test]
fn summarize_path_permissions_counts_pass_fail() {
let mut report = minimal_report();
report.path_permission_checks = vec![
PathPermissionCheck {
path: "/a".into(),
purpose: "x".into(),
required: "read".into(),
exists: true,
readable: true,
writable: true,
creatable: true,
ok: true,
message: None,
},
PathPermissionCheck {
path: "/b".into(),
purpose: "y".into(),
required: "write".into(),
exists: false,
readable: false,
writable: false,
creatable: false,
ok: false,
message: Some("no".into()),
},
];
let (pass, total) = super::summarize_path_permissions(&report);
assert_eq!(pass, 1);
assert_eq!(total, 2);
}
#[test]
fn discover_env_files_finds_dotenv() {
let dir = std::env::temp_dir().join(format!(
"xbp-diag-env-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(".env"), "A=1\n").unwrap();
fs::write(dir.join(".env.local"), "B=2\n").unwrap();
let found = discover_env_files(&dir);
assert!(found.iter().any(|f| f == ".env"));
assert!(found.iter().any(|f| f == ".env.local"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn diag_bool_and_ratio_helpers() {
assert!(diag_bool(true).to_string().contains("true"));
assert!(diag_bool(false).to_string().contains("false"));
assert!(diag_ratio(2, 5).contains('2'));
assert!(diag_bool_fragment("x", true).contains("x="));
}
#[test]
fn color_service_status_classifies() {
assert!(color_service_status("running").to_string().contains("running"));
assert!(color_service_status("failed").to_string().contains("failed"));
assert!(color_service_status("degraded").to_string().contains("degraded"));
}
#[test]
fn render_feature_flags_sorted() {
let mut flags = HashMap::new();
flags.insert("z".into(), true);
flags.insert("a".into(), false);
let s = render_feature_flags(&flags);
assert!(s.find('a').unwrap() < s.find('z').unwrap());
}
#[test]
fn section_badge_variants() {
for state in [
SectionState::Healthy,
SectionState::Warn,
SectionState::Fail,
SectionState::Info,
] {
assert!(!section_badge(state).to_string().is_empty());
}
}
}