Skip to main content

xbp_cli/commands/
system_diag.rs

1use crate::codetime::SystemInventory;
2use crate::commands::service::load_xbp_config;
3use crate::config::sync_system_inventory;
4use crate::logging::{log_info, log_warn};
5use crate::strategies::{get_all_services, ProjectDetector};
6use crate::utils::{collect_listening_port_ownership, command_exists, ListeningPortOwnership};
7use anyhow::Result;
8use colored::Colorize;
9use indicatif::{ProgressBar, ProgressStyle};
10use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashMap};
13use std::env;
14use std::fs;
15use std::fs::OpenOptions;
16use std::path::PathBuf;
17use std::time::{Duration, Instant};
18use sysinfo::{Disks, Networks, System};
19use tokio::process::Command;
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct SystemMetrics {
23    pub cpu_usage: f32,
24    pub memory_total: u64,
25    pub memory_used: u64,
26    pub memory_percent: f32,
27    pub disk_total: u64,
28    pub disk_used: u64,
29    pub disk_percent: f32,
30    pub network_rx: u64,
31    pub network_tx: u64,
32    pub uptime: u64,
33    pub process_count: usize,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
37pub struct PortCheck {
38    pub port: u16,
39    pub is_open: bool,
40    pub is_blocked: bool,
41    pub pids: Vec<u32>,
42    pub xbp_projects: Vec<String>,
43}
44
45#[derive(Debug, Serialize, Deserialize)]
46pub struct InternetSpeed {
47    pub download_mbps: f64,
48    pub upload_mbps: f64,
49    pub ping_ms: f64,
50}
51
52#[derive(Debug, Serialize, Deserialize)]
53pub struct OsInfo {
54    pub name: Option<String>,
55    pub version: Option<String>,
56    pub kernel_version: Option<String>,
57    pub arch: Option<String>,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61pub struct CpuInfo {
62    pub brand: Option<String>,
63    pub cores: usize,
64    pub usage: f32,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct DiskInfo {
69    pub mount: String,
70    pub fs: Option<String>,
71    pub total: u64,
72    pub used: u64,
73    pub percent: f32,
74    pub bar: String,
75    pub is_current: bool,
76}
77
78#[derive(Debug, Serialize, Deserialize)]
79pub struct ShellInfo {
80    pub shell: Option<String>,
81    pub term: Option<String>,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85pub struct ProxyDetection {
86    pub nginx_sites_available: Option<usize>,
87    pub nginx_active: Option<bool>,
88    pub traefik_active: Option<bool>,
89    pub apache_active: Option<bool>,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93pub struct ExposureInfo {
94    pub public_ipv4: Option<String>,
95    pub listening_on_all_interfaces: Vec<String>,
96    pub firewall_hint: Option<String>,
97    pub summary: Option<String>,
98}
99
100#[derive(Debug, Serialize, Deserialize)]
101pub struct ToolVersion {
102    pub present: bool,
103    pub version: Option<String>,
104}
105
106#[derive(Debug, Serialize, Deserialize)]
107pub struct PathPermissionCheck {
108    pub path: String,
109    pub purpose: String,
110    pub required: String,
111    pub exists: bool,
112    pub readable: bool,
113    pub writable: bool,
114    pub creatable: bool,
115    pub ok: bool,
116    pub message: Option<String>,
117}
118
119#[derive(Debug, Serialize, Deserialize)]
120pub struct DiagnosticReport {
121    pub system_metrics: SystemMetrics,
122    pub os: Option<OsInfo>,
123    pub cpu: Option<CpuInfo>,
124    pub gpu_candidates: Vec<String>,
125    pub disks: Vec<DiskInfo>,
126    pub shell: Option<ShellInfo>,
127    pub proxy_detection: Option<ProxyDetection>,
128    pub clipboard_tools: HashMap<String, bool>,
129    pub exposure: Option<ExposureInfo>,
130    pub pm2_process_count: Option<usize>,
131    pub tool_versions: HashMap<String, ToolVersion>,
132    pub service_statuses: HashMap<String, String>,
133    pub feature_flags: HashMap<String, bool>,
134    pub xbp_cli_version: String,
135    pub project_config_present: bool,
136    pub configured_services: Vec<String>,
137    pub provider_manifests: Vec<String>,
138    pub nginx_status: Option<NginxStatus>,
139    pub port_checks: Vec<PortCheck>,
140    pub internet_speed: Option<InternetSpeed>,
141    pub connectivity: bool,
142    pub installed_programs: HashMap<String, bool>,
143    pub path_permission_checks: Vec<PathPermissionCheck>,
144    pub codetime: Option<CodeTimeDiagnostic>,
145}
146
147#[derive(Debug, Serialize, Deserialize)]
148pub struct CodeTimeDiagnostic {
149    pub saved_config_path: String,
150    pub refreshed: bool,
151    pub inventory: SystemInventory,
152}
153
154#[derive(Debug, Serialize, Deserialize)]
155pub struct NginxStatus {
156    pub is_running: bool,
157    pub is_enabled: bool,
158    pub config_valid: bool,
159    pub error: Option<String>,
160}
161
162const PM2_DIAG_TIMEOUT: Duration = Duration::from_secs(12);
163const PORT_OWNERSHIP_TIMEOUT: Duration = Duration::from_secs(20);
164const PER_PORT_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
165const TOOL_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
166const PROGRAM_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
167const COMMAND_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
168const INTERNET_SPEED_TIMEOUT: Duration = Duration::from_secs(20);
169const NGINX_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
170const SYSTEM_INVENTORY_TIMEOUT: Duration = Duration::from_secs(25);
171const DOWNLOAD_PROBE_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
172const DOWNLOAD_PROBE_MAX_DURATION: Duration = Duration::from_secs(8);
173const DOWNLOAD_PROBE_TARGET_BYTES: u64 = 8_000_000;
174const DOWNLOAD_PROBE_MIN_BYTES: u64 = 1_000_000;
175const DOWNLOAD_PROBE_URLS: &[&str] = &[
176    "https://speed.cloudflare.com/__down?bytes=20000000",
177    "https://speed.hetzner.de/10MB.bin",
178    "https://proof.ovh.net/files/10Mb.dat",
179];
180
181#[derive(Debug, Clone, Copy, Default)]
182pub struct DiagnosticRunOptions {
183    pub skip_speed_test: bool,
184    pub codetime: bool,
185    pub cursor: bool,
186}
187
188pub async fn get_system_metrics() -> Result<SystemMetrics> {
189    let mut sys = System::new_all();
190    sys.refresh_all();
191
192    let cpu_usage =
193        sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
194    let memory_total = sys.total_memory();
195    let memory_used = sys.used_memory();
196    let memory_percent = (memory_used as f32 / memory_total as f32) * 100.0;
197
198    let mut disk_total = 0;
199    let mut disk_used = 0;
200    for disk in Disks::new_with_refreshed_list().iter() {
201        disk_total += disk.total_space();
202        disk_used += disk.total_space() - disk.available_space();
203    }
204    let disk_percent = if disk_total > 0 {
205        (disk_used as f32 / disk_total as f32) * 100.0
206    } else {
207        0.0
208    };
209
210    let networks = Networks::new_with_refreshed_list();
211    let mut network_rx = 0;
212    let mut network_tx = 0;
213    for (_interface_name, network) in &networks {
214        network_rx += network.total_received();
215        network_tx += network.total_transmitted();
216    }
217
218    let uptime = System::uptime();
219    let process_count = sys.processes().len();
220
221    Ok(SystemMetrics {
222        cpu_usage,
223        memory_total,
224        memory_used,
225        memory_percent,
226        disk_total,
227        disk_used,
228        disk_percent,
229        network_rx,
230        network_tx,
231        uptime,
232        process_count,
233    })
234}
235
236pub async fn check_nginx_status() -> Result<NginxStatus> {
237    let is_running: bool = check_systemctl_status("nginx").await?;
238    let is_enabled: bool = check_systemctl_enabled("nginx").await?;
239
240    let config_valid: bool = if is_running {
241        match Command::new("nginx").arg("-t").output().await {
242            Ok(output) => output.status.success(),
243            Err(_) => false,
244        }
245    } else {
246        false
247    };
248
249    Ok(NginxStatus {
250        is_running,
251        is_enabled,
252        config_valid,
253        error: None,
254    })
255}
256
257async fn check_systemctl_status(service: &str) -> Result<bool> {
258    if !cfg!(target_os = "linux") || !command_exists("systemctl") {
259        return Ok(false);
260    }
261
262    let output = Command::new("systemctl")
263        .arg("is-active")
264        .arg(service)
265        .output()
266        .await?;
267
268    Ok(output.status.success())
269}
270
271async fn check_systemctl_enabled(service: &str) -> Result<bool> {
272    if !cfg!(target_os = "linux") || !command_exists("systemctl") {
273        return Ok(false);
274    }
275
276    let output = Command::new("systemctl")
277        .arg("is-enabled")
278        .arg(service)
279        .output()
280        .await?;
281
282    Ok(output.status.success())
283}
284
285pub async fn check_port_availability(
286    port: u16,
287    ownership: Option<&ListeningPortOwnership>,
288) -> Result<PortCheck> {
289    use std::net::TcpListener;
290
291    let is_open: bool = TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok();
292
293    let is_blocked: bool = if cfg!(target_os = "linux") {
294        check_firewall_blocked(port).await.unwrap_or(false)
295    } else {
296        false
297    };
298
299    Ok(PortCheck {
300        port,
301        is_open,
302        is_blocked,
303        pids: ownership
304            .map(|value| value.pids.clone())
305            .unwrap_or_default(),
306        xbp_projects: ownership
307            .map(|value| value.xbp_projects.clone())
308            .unwrap_or_default(),
309    })
310}
311
312async fn check_firewall_blocked(port: u16) -> Result<bool> {
313    let output: std::process::Output = Command::new("iptables")
314        .arg("-L")
315        .arg("-n")
316        .output()
317        .await?;
318
319    if !output.status.success() {
320        return Ok(false);
321    }
322
323    let stdout = String::from_utf8_lossy(&output.stdout);
324    let port_str = port.to_string();
325    Ok(stdout.contains(&format!("dpt:{}", port_str)) && stdout.contains("DROP"))
326}
327
328pub async fn check_internet_connectivity() -> Result<bool> {
329    let result: std::process::Output = if cfg!(target_os = "windows") {
330        Command::new("ping")
331            .arg("-n")
332            .arg("1")
333            .arg("-w")
334            .arg("2000")
335            .arg("8.8.8.8")
336            .output()
337            .await?
338    } else {
339        Command::new("ping")
340            .arg("-c")
341            .arg("1")
342            .arg("-W")
343            .arg("2")
344            .arg("8.8.8.8")
345            .output()
346            .await?
347    };
348
349    Ok(result.status.success())
350}
351
352pub async fn measure_internet_speed() -> Result<InternetSpeed> {
353    if let Ok(speed) = measure_speed_with_speedtest_cli().await {
354        if speed.download_mbps >= 1.0 || speed.upload_mbps >= 0.5 {
355            return Ok(speed);
356        }
357    }
358
359    let ping: f64 = measure_ping().await?;
360    let download_mbps = measure_download_speed().await.unwrap_or(0.0);
361    let upload_mbps = 0.0;
362
363    Ok(InternetSpeed {
364        download_mbps,
365        upload_mbps,
366        ping_ms: ping,
367    })
368}
369
370fn parse_speed_value(token: &str) -> Option<f64> {
371    token.trim().replace(',', ".").parse::<f64>().ok()
372}
373
374async fn measure_speed_with_speedtest_cli() -> Result<InternetSpeed> {
375    let output: std::process::Output = Command::new("speedtest-cli")
376        .arg("--simple")
377        .output()
378        .await?;
379
380    if !output.status.success() {
381        return Err(anyhow::anyhow!("speedtest-cli failed"));
382    }
383
384    let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
385    let mut ping_ms: f64 = 0.0;
386    let mut download_mbps: f64 = 0.0;
387    let mut upload_mbps: f64 = 0.0;
388
389    for line in stdout.lines() {
390        if line.starts_with("Ping:") {
391            if let Some(val) = line.split_whitespace().nth(1) {
392                ping_ms = parse_speed_value(val).unwrap_or(0.0);
393            }
394        } else if line.starts_with("Download:") {
395            if let Some(val) = line.split_whitespace().nth(1) {
396                download_mbps = parse_speed_value(val).unwrap_or(0.0);
397            }
398        } else if line.starts_with("Upload:") {
399            if let Some(val) = line.split_whitespace().nth(1) {
400                upload_mbps = parse_speed_value(val).unwrap_or(0.0);
401            }
402        }
403    }
404
405    Ok(InternetSpeed {
406        download_mbps,
407        upload_mbps,
408        ping_ms,
409    })
410}
411
412async fn measure_ping() -> Result<f64> {
413    let output: std::process::Output = if cfg!(target_os = "windows") {
414        Command::new("ping")
415            .arg("-n")
416            .arg("4")
417            .arg("8.8.8.8")
418            .output()
419            .await?
420    } else {
421        Command::new("ping")
422            .arg("-c")
423            .arg("4")
424            .arg("8.8.8.8")
425            .output()
426            .await?
427    };
428
429    if !output.status.success() {
430        return Ok(0.0);
431    }
432
433    let stdout = String::from_utf8_lossy(&output.stdout);
434
435    if cfg!(target_os = "windows") {
436        for line in stdout.lines() {
437            if line.contains("Average") {
438                if let Some(avg_part) = line.split('=').next_back() {
439                    let avg_str = avg_part.trim().trim_end_matches("ms");
440                    if let Ok(avg) = avg_str.parse::<f64>() {
441                        return Ok(avg);
442                    }
443                }
444            }
445        }
446    } else {
447        for line in stdout.lines() {
448            if line.contains("avg") || line.contains("rtt") {
449                if let Some(avg_str) = line.split('/').nth(4) {
450                    if let Ok(avg) = avg_str.trim().parse::<f64>() {
451                        return Ok(avg);
452                    }
453                }
454            }
455        }
456    }
457
458    Ok(0.0)
459}
460
461async fn measure_download_speed() -> Result<f64> {
462    let client: reqwest::Client = reqwest::Client::builder()
463        .timeout(DOWNLOAD_PROBE_REQUEST_TIMEOUT)
464        .build()?;
465    let mut last_error: Option<String> = None;
466
467    for url in DOWNLOAD_PROBE_URLS {
468        match measure_download_speed_probe(&client, url).await {
469            Ok(mbps) => return Ok(mbps),
470            Err(err) => last_error = Some(err.to_string()),
471        }
472    }
473
474    Err(anyhow::anyhow!(
475        "all download probes failed{}",
476        last_error
477            .map(|value| format!(" (last error: {})", value))
478            .unwrap_or_default()
479    ))
480}
481
482async fn measure_download_speed_probe(client: &reqwest::Client, url: &str) -> Result<f64> {
483    let start = Instant::now();
484    let mut response: reqwest::Response = client
485        .get(url)
486        .header("Cache-Control", "no-cache")
487        .header("Pragma", "no-cache")
488        .send()
489        .await?;
490
491    if !response.status().is_success() {
492        return Err(anyhow::anyhow!(
493            "download probe {} returned status {}",
494            url,
495            response.status()
496        ));
497    }
498
499    let mut total_bytes: u64 = 0;
500
501    loop {
502        if total_bytes >= DOWNLOAD_PROBE_TARGET_BYTES
503            || start.elapsed() >= DOWNLOAD_PROBE_MAX_DURATION
504        {
505            break;
506        }
507
508        let chunk = response.chunk().await?;
509        let Some(bytes) = chunk else {
510            break;
511        };
512
513        total_bytes = total_bytes.saturating_add(bytes.len() as u64);
514    }
515
516    if total_bytes < DOWNLOAD_PROBE_MIN_BYTES {
517        return Err(anyhow::anyhow!(
518            "insufficient sample from {}: {} bytes",
519            url,
520            total_bytes
521        ));
522    }
523
524    let elapsed_seconds = start.elapsed().as_secs_f64().max(0.001);
525    let megabits = (total_bytes as f64 * 8.0) / 1_000_000.0;
526    Ok(megabits / elapsed_seconds)
527}
528
529pub async fn check_installed_programs() -> HashMap<String, bool> {
530    let programs = vec![
531        "jq",
532        "curl",
533        "nginx",
534        "git",
535        "docker",
536        "kubectl",
537        "microk8s",
538        "node",
539        "npm",
540        "python",
541        "python3",
542        "pip",
543        "pip3",
544        "cargo",
545        "rustc",
546        "pm2",
547        "speedtest-cli",
548        "systemctl",
549        "launchctl",
550    ];
551
552    let mut results = HashMap::new();
553
554    for program in programs {
555        let is_installed = check_program_installed(program).await;
556        results.insert(program.to_string(), is_installed);
557    }
558
559    results
560}
561
562fn can_create_file_in_dir(dir: &std::path::Path) -> bool {
563    use std::time::{SystemTime, UNIX_EPOCH};
564
565    if !dir.is_dir() {
566        return false;
567    }
568
569    let nonce = SystemTime::now()
570        .duration_since(UNIX_EPOCH)
571        .map(|d| d.as_nanos())
572        .unwrap_or_default();
573    let probe = dir.join(format!(
574        ".xbp_diag_perm_probe_{}_{}",
575        std::process::id(),
576        nonce
577    ));
578
579    match OpenOptions::new().write(true).create_new(true).open(&probe) {
580        Ok(_) => {
581            let _ = fs::remove_file(&probe);
582            true
583        }
584        Err(_) => false,
585    }
586}
587
588fn check_path_permission_entry(
589    path: PathBuf,
590    purpose: &str,
591    require_read: bool,
592    require_write: bool,
593    require_create: bool,
594    optional: bool,
595) -> PathPermissionCheck {
596    let exists = path.exists();
597    let mut readable = false;
598    let mut writable = false;
599    let mut creatable = false;
600    let mut message = None;
601
602    if exists {
603        if path.is_dir() {
604            readable = fs::read_dir(&path).is_ok();
605            writable = can_create_file_in_dir(&path);
606            creatable = writable;
607        } else if path.is_file() {
608            readable = fs::File::open(&path).is_ok();
609            writable = OpenOptions::new().write(true).open(&path).is_ok();
610            if let Some(parent) = path.parent() {
611                creatable = can_create_file_in_dir(parent);
612            }
613        } else {
614            message = Some("Path exists but is not a regular file/directory".to_string());
615        }
616    } else if let Some(parent) = path.parent() {
617        // Missing path: we can still estimate create capability via parent directory.
618        creatable = can_create_file_in_dir(parent);
619        readable = parent.exists() && fs::read_dir(parent).is_ok();
620        writable = parent.exists() && can_create_file_in_dir(parent);
621        if optional {
622            message = Some("Path missing (optional)".to_string());
623        } else if require_create && !creatable {
624            message =
625                Some("Path missing and cannot be created with current permissions".to_string());
626        } else {
627            message = Some("Path missing".to_string());
628        }
629    } else if optional {
630        message = Some("Path missing (optional)".to_string());
631    } else {
632        message = Some("Path missing".to_string());
633    }
634
635    let mut missing = Vec::new();
636    if require_read && !readable {
637        missing.push("read");
638    }
639    if require_write && !writable {
640        missing.push("write");
641    }
642    if require_create && !creatable {
643        missing.push("create");
644    }
645
646    let mut ok = missing.is_empty();
647    if optional && !exists {
648        ok = true;
649    }
650    if !ok {
651        let detail = format!("Missing required access: {}", missing.join(", "));
652        message = match message {
653            Some(existing) => Some(format!("{}; {}", existing, detail)),
654            None => Some(detail),
655        };
656    }
657
658    let required = match (require_read, require_write, require_create) {
659        (true, true, true) => "read/write/create",
660        (true, true, false) => "read/write",
661        (true, false, true) => "read/create",
662        (false, true, true) => "write/create",
663        (true, false, false) => "read",
664        (false, true, false) => "write",
665        (false, false, true) => "create",
666        (false, false, false) => "none",
667    }
668    .to_string();
669
670    PathPermissionCheck {
671        path: path.display().to_string(),
672        purpose: purpose.to_string(),
673        required,
674        exists,
675        readable,
676        writable,
677        creatable,
678        ok,
679        message,
680    }
681}
682
683async fn get_path_permission_checks() -> Vec<PathPermissionCheck> {
684    let mut checks = Vec::new();
685
686    if cfg!(target_os = "linux") {
687        checks.push(check_path_permission_entry(
688            PathBuf::from("/etc/systemd/system"),
689            "Install/update systemd unit files",
690            true,
691            true,
692            true,
693            false,
694        ));
695        checks.push(check_path_permission_entry(
696            PathBuf::from("/etc/default/xbp"),
697            "Optional runtime environment file for xbp-api.service",
698            true,
699            true,
700            true,
701            true,
702        ));
703        checks.push(check_path_permission_entry(
704            PathBuf::from("/etc/nginx"),
705            "Read/write Nginx configuration root",
706            true,
707            true,
708            false,
709            false,
710        ));
711        checks.push(check_path_permission_entry(
712            PathBuf::from("/etc/nginx/sites-available"),
713            "Manage Nginx site configurations",
714            true,
715            true,
716            true,
717            false,
718        ));
719        checks.push(check_path_permission_entry(
720            PathBuf::from("/etc/nginx/sites-enabled"),
721            "Enable/disable Nginx sites",
722            true,
723            true,
724            true,
725            false,
726        ));
727        checks.push(check_path_permission_entry(
728            PathBuf::from("/var/log"),
729            "Write XBP and service log files",
730            true,
731            true,
732            true,
733            false,
734        ));
735        checks.push(check_path_permission_entry(
736            PathBuf::from("/var/lib/xbp"),
737            "State directory used by generated systemd services",
738            true,
739            true,
740            true,
741            true,
742        ));
743        checks.push(check_path_permission_entry(
744            PathBuf::from("/var/log/nginx"),
745            "Read Nginx access/error logs for diagnostics and metrics",
746            true,
747            true,
748            true,
749            true,
750        ));
751        checks.push(check_path_permission_entry(
752            PathBuf::from("/run"),
753            "Runtime sockets/state files used by services",
754            true,
755            true,
756            true,
757            false,
758        ));
759        checks.push(check_path_permission_entry(
760            PathBuf::from("/usr/local/bin/xbp"),
761            "Installed XBP CLI binary path",
762            true,
763            true,
764            true,
765            true,
766        ));
767    } else if let Ok(current) = env::current_dir() {
768        checks.push(check_path_permission_entry(
769            current,
770            "Current project directory",
771            true,
772            true,
773            true,
774            false,
775        ));
776    }
777
778    if let Ok(current) = env::current_dir() {
779        checks.push(check_path_permission_entry(
780            current.join(".xbp"),
781            "Local XBP project metadata directory",
782            true,
783            true,
784            true,
785            true,
786        ));
787    }
788
789    checks
790}
791
792async fn run_command_with_timeout(
793    program: &str,
794    args: &[&str],
795    timeout: Duration,
796) -> Option<std::process::Output> {
797    let mut cmd = Command::new(program);
798    cmd.args(args);
799    cmd.kill_on_drop(true);
800    match tokio::time::timeout(timeout, cmd.output()).await {
801        Ok(Ok(output)) => Some(output),
802        _ => None,
803    }
804}
805
806async fn check_program_installed(program: &str) -> bool {
807    let output = if cfg!(target_os = "windows") {
808        run_command_with_timeout("where", &[program], PROGRAM_CHECK_TIMEOUT).await
809    } else {
810        run_command_with_timeout("which", &[program], PROGRAM_CHECK_TIMEOUT).await
811    };
812
813    output.map(|value| value.status.success()).unwrap_or(false)
814}
815
816fn render_percent_bar(percent: f32, width: usize) -> String {
817    let pct = percent.clamp(0.0, 100.0);
818    let filled = ((pct / 100.0) * width as f32).round() as usize;
819    let filled = filled.min(width);
820    let empty = width.saturating_sub(filled);
821    format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty))
822}
823
824async fn get_os_info() -> Option<OsInfo> {
825    Some(OsInfo {
826        name: System::name(),
827        version: System::os_version(),
828        kernel_version: System::kernel_version(),
829        arch: Some(std::env::consts::ARCH.to_string()),
830    })
831}
832
833async fn get_cpu_info() -> Option<CpuInfo> {
834    let mut sys = System::new_all();
835    sys.refresh_cpu_all();
836    let cores = sys.cpus().len();
837    let usage = if cores > 0 {
838        sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / cores as f32
839    } else {
840        0.0
841    };
842    let brand = sys.cpus().first().map(|c| c.brand().to_string());
843    Some(CpuInfo {
844        brand,
845        cores,
846        usage,
847    })
848}
849
850async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
851    let output = run_command_with_timeout(program, args, COMMAND_CAPTURE_TIMEOUT).await?;
852    let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
853    let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
854    let combined: String = if !stdout.is_empty() { stdout } else { stderr };
855    if combined.is_empty() {
856        None
857    } else {
858        Some(combined)
859    }
860}
861
862async fn get_gpu_candidates() -> Vec<String> {
863    let mut out: Vec<String> = Vec::new();
864
865    if cfg!(target_os = "windows") {
866        if let Some(s) = run_command_capture(
867            "powershell",
868            &[
869                "-Command",
870                "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
871            ],
872        )
873        .await
874        {
875            for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
876                out.push(line.to_string());
877            }
878        }
879        return out;
880    }
881
882    if let Some(s) = run_command_capture("nvidia-smi", &["-L"]).await {
883        for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
884            out.push(line.to_string());
885        }
886    }
887
888    if out.is_empty() {
889        if let Some(s) =
890            run_command_capture("sh", &["-c", "lspci | grep -Ei 'vga|3d|display'"]).await
891        {
892            for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
893                out.push(line.to_string());
894            }
895        }
896    }
897
898    if out.is_empty() {
899        if let Some(s) =
900            run_command_capture("sh", &["-c", "lshw -C display 2>/dev/null | head"]).await
901        {
902            for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
903                out.push(line.to_string());
904            }
905        }
906    }
907
908    out
909}
910
911async fn get_disk_infos() -> Vec<DiskInfo> {
912    let disks = Disks::new_with_refreshed_list();
913    let cwd = env::current_dir().ok();
914
915    let mut candidates: Vec<(usize, usize)> = Vec::new();
916    if let Some(cwd) = &cwd {
917        for (i, d) in disks.iter().enumerate() {
918            let mp = d.mount_point();
919            if cwd.starts_with(mp) {
920                candidates.push((mp.as_os_str().len(), i));
921            }
922        }
923    }
924    candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
925    let current_idx = candidates.first().map(|(_, idx)| *idx);
926
927    disks
928        .iter()
929        .enumerate()
930        .map(|(i, d)| {
931            let total = d.total_space();
932            let used = total.saturating_sub(d.available_space());
933            let percent = if total > 0 {
934                (used as f32 / total as f32) * 100.0
935            } else {
936                0.0
937            };
938            let mount = d.mount_point().to_string_lossy().to_string();
939            let fs = if d.file_system().is_empty() {
940                None
941            } else {
942                Some(d.file_system().to_string_lossy().to_string())
943            };
944            DiskInfo {
945                mount,
946                fs,
947                total,
948                used,
949                percent,
950                bar: render_percent_bar(percent, 20),
951                is_current: current_idx == Some(i),
952            }
953        })
954        .collect()
955}
956
957fn get_shell_info() -> Option<ShellInfo> {
958    let shell = env::var("SHELL")
959        .ok()
960        .or_else(|| env::var("ComSpec").ok())
961        .or_else(|| env::var("0").ok());
962    let term = env::var("TERM").ok();
963    if shell.is_none() && term.is_none() {
964        None
965    } else {
966        Some(ShellInfo { shell, term })
967    }
968}
969
970async fn systemctl_active(service: &str) -> Option<bool> {
971    if !cfg!(target_os = "linux") || !command_exists("systemctl") {
972        return None;
973    }
974    let output = Command::new("systemctl")
975        .arg("is-active")
976        .arg(service)
977        .output()
978        .await
979        .ok()?;
980    Some(output.status.success())
981}
982
983async fn get_service_statuses() -> HashMap<String, String> {
984    let mut out = HashMap::new();
985    let services = [
986        "nginx",
987        "traefik",
988        "apache2",
989        "httpd",
990        "postgresql",
991        "postgrest",
992        "prometheus",
993        "grafana-server",
994        "kafka",
995        "xbp",
996    ];
997
998    for s in services {
999        let v = systemctl_active(s).await;
1000        let status = match v {
1001            Some(true) => "active",
1002            Some(false) => "inactive",
1003            None => "unknown",
1004        };
1005        out.insert(s.to_string(), status.to_string());
1006    }
1007
1008    out
1009}
1010
1011async fn get_proxy_detection() -> Option<ProxyDetection> {
1012    let nginx_sites_available = if cfg!(target_os = "linux") {
1013        let p = PathBuf::from("/etc/nginx/sites-available");
1014        if p.exists() {
1015            fs::read_dir(&p)
1016                .ok()
1017                .map(|rd| rd.filter_map(|e| e.ok()).count())
1018        } else {
1019            None
1020        }
1021    } else {
1022        None
1023    };
1024
1025    let nginx_active = systemctl_active("nginx").await;
1026    let traefik_active = systemctl_active("traefik").await;
1027    let apache_active = match systemctl_active("apache2").await {
1028        Some(v) => Some(v),
1029        None => systemctl_active("httpd").await,
1030    };
1031
1032    if nginx_sites_available.is_none()
1033        && nginx_active.is_none()
1034        && traefik_active.is_none()
1035        && apache_active.is_none()
1036    {
1037        None
1038    } else {
1039        Some(ProxyDetection {
1040            nginx_sites_available,
1041            nginx_active,
1042            traefik_active,
1043            apache_active,
1044        })
1045    }
1046}
1047
1048async fn get_clipboard_tools() -> HashMap<String, bool> {
1049    let mut tools = vec!["xclip", "xsel", "wl-copy", "pbcopy"];
1050    if cfg!(target_os = "windows") {
1051        tools.push("clip");
1052    }
1053    let mut out = HashMap::new();
1054    for t in tools {
1055        out.insert(t.to_string(), check_program_installed(t).await);
1056    }
1057    out
1058}
1059
1060fn get_listeners_on_all_interfaces() -> Vec<String> {
1061    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
1062    let proto_flags = ProtocolFlags::TCP;
1063    let sockets = get_sockets_info(af_flags, proto_flags).unwrap_or_default();
1064
1065    let mut out = Vec::new();
1066    for socket in sockets {
1067        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
1068            let state = format!("{:?}", tcp.state);
1069            if state != "Listen" && state != "LISTEN" {
1070                continue;
1071            }
1072            let addr = tcp.local_addr.to_string();
1073            if addr == "0.0.0.0" || addr == "::" {
1074                out.push(format!("{}:{}", addr, tcp.local_port));
1075            }
1076        }
1077    }
1078    out.sort();
1079    out.dedup();
1080    out
1081}
1082
1083async fn get_firewall_hint() -> Option<String> {
1084    if !cfg!(target_os = "linux") {
1085        return None;
1086    }
1087
1088    if let Some(s) = run_command_capture("sh", &["-c", "ufw status 2>/dev/null | head -n 1"]).await
1089    {
1090        if !s.trim().is_empty() {
1091            return Some(s.trim().to_string());
1092        }
1093    }
1094
1095    if let Some(s) =
1096        run_command_capture("sh", &["-c", "nft list ruleset 2>/dev/null | head -n 1"]).await
1097    {
1098        if !s.trim().is_empty() {
1099            return Some("nftables detected".to_string());
1100        }
1101    }
1102
1103    if let Some(s) = run_command_capture("sh", &["-c", "iptables -S 2>/dev/null | head -n 1"]).await
1104    {
1105        if !s.trim().is_empty() {
1106            return Some("iptables detected".to_string());
1107        }
1108    }
1109
1110    None
1111}
1112
1113async fn get_public_ipv4(connectivity: bool) -> Option<String> {
1114    if !connectivity {
1115        return None;
1116    }
1117    let client = reqwest::Client::builder()
1118        .timeout(Duration::from_secs(3))
1119        .build()
1120        .ok()?;
1121    let text = client
1122        .get("https://api.ipify.org")
1123        .send()
1124        .await
1125        .ok()?
1126        .text()
1127        .await
1128        .ok()?;
1129    let ip = text.trim().to_string();
1130    if ip.is_empty() {
1131        None
1132    } else {
1133        Some(ip)
1134    }
1135}
1136
1137async fn get_exposure_info(connectivity: bool) -> Option<ExposureInfo> {
1138    let public_ipv4 = get_public_ipv4(connectivity).await;
1139    let listening_on_all_interfaces = get_listeners_on_all_interfaces();
1140    let firewall_hint = get_firewall_hint().await;
1141    let summary = Some(if listening_on_all_interfaces.is_empty() {
1142        "No listeners on 0.0.0.0/:: detected".to_string()
1143    } else {
1144        "Listeners on 0.0.0.0/:: detected (may be publicly reachable)".to_string()
1145    });
1146    Some(ExposureInfo {
1147        public_ipv4,
1148        listening_on_all_interfaces,
1149        firewall_hint,
1150        summary,
1151    })
1152}
1153
1154async fn get_pm2_process_count() -> Option<usize> {
1155    let mut cmd = if cfg!(target_os = "windows") {
1156        let mut cmd = Command::new("powershell");
1157        cmd.arg("-NoProfile").arg("-Command").arg("pm2 jlist");
1158        cmd
1159    } else {
1160        let mut cmd = Command::new("pm2");
1161        cmd.arg("jlist");
1162        cmd
1163    };
1164    cmd.kill_on_drop(true);
1165
1166    let output = match tokio::time::timeout(PM2_DIAG_TIMEOUT, cmd.output()).await {
1167        Ok(Ok(output)) => output,
1168        Ok(Err(_)) | Err(_) => return None,
1169    };
1170
1171    if !output.status.success() {
1172        return None;
1173    }
1174    let stdout = String::from_utf8_lossy(&output.stdout);
1175    let value: serde_json::Value = serde_json::from_str(&stdout).ok()?;
1176    value.as_array().map(|arr| arr.len())
1177}
1178
1179async fn get_version(cmd: &str, args: &[&str]) -> ToolVersion {
1180    match run_command_with_timeout(cmd, args, TOOL_VERSION_TIMEOUT).await {
1181        Some(o) => {
1182            let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
1183            let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1184            let line = stdout
1185                .lines()
1186                .next()
1187                .filter(|l| !l.trim().is_empty())
1188                .map(|l| l.trim().to_string())
1189                .or_else(|| {
1190                    stderr
1191                        .lines()
1192                        .next()
1193                        .filter(|l| !l.trim().is_empty())
1194                        .map(|l| l.trim().to_string())
1195                });
1196            ToolVersion {
1197                present: o.status.success() || line.is_some(),
1198                version: line,
1199            }
1200        }
1201        None => ToolVersion {
1202            present: false,
1203            version: None,
1204        },
1205    }
1206}
1207
1208async fn get_tool_versions() -> HashMap<String, ToolVersion> {
1209    let mut out = HashMap::new();
1210
1211    out.insert(
1212        "python3".to_string(),
1213        get_version("python3", &["--version"]).await,
1214    );
1215    out.insert(
1216        "python".to_string(),
1217        get_version("python", &["--version"]).await,
1218    );
1219    out.insert(
1220        "node".to_string(),
1221        get_version("node", &["--version"]).await,
1222    );
1223    out.insert("npm".to_string(), get_version("npm", &["--version"]).await);
1224    out.insert(
1225        "pnpm".to_string(),
1226        get_version("pnpm", &["--version"]).await,
1227    );
1228    out.insert("pm2".to_string(), get_version("pm2", &["--version"]).await);
1229    out.insert(
1230        "docker".to_string(),
1231        get_version("docker", &["--version"]).await,
1232    );
1233    out.insert(
1234        "docker-compose".to_string(),
1235        get_version("docker-compose", &["--version"]).await,
1236    );
1237    out.insert(
1238        "kubectl".to_string(),
1239        get_version("kubectl", &["version", "--client"]).await,
1240    );
1241    out.insert(
1242        "microk8s".to_string(),
1243        get_version("microk8s", &["version"]).await,
1244    );
1245    out.insert(
1246        "rustc".to_string(),
1247        get_version("rustc", &["--version"]).await,
1248    );
1249    out.insert(
1250        "cargo".to_string(),
1251        get_version("cargo", &["--version"]).await,
1252    );
1253    out.insert(
1254        "rustup".to_string(),
1255        get_version("rustup", &["--version"]).await,
1256    );
1257
1258    out
1259}
1260
1261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1262enum SectionState {
1263    Healthy,
1264    Warn,
1265    Fail,
1266    Info,
1267}
1268
1269fn section_badge(state: SectionState) -> colored::ColoredString {
1270    match state {
1271        SectionState::Healthy => "OK".green().bold(),
1272        SectionState::Warn => "WARN".yellow().bold(),
1273        SectionState::Fail => "FAIL".red().bold(),
1274        SectionState::Info => "INFO".cyan().bold(),
1275    }
1276}
1277
1278fn print_section_header(title: &str, state: SectionState, summary: &str) {
1279    println!(
1280        "\n{} {} {}",
1281        section_badge(state),
1282        title.bright_white().bold(),
1283        summary.bright_black()
1284    );
1285}
1286
1287fn diag_bool(value: bool) -> colored::ColoredString {
1288    if value {
1289        "true".green()
1290    } else {
1291        "false".red()
1292    }
1293}
1294
1295fn diag_bool_fragment(label: &str, value: bool) -> String {
1296    format!("{}={}", label, diag_bool(value))
1297}
1298
1299fn diag_ratio(passing: usize, total: usize) -> String {
1300    format!(
1301        "{}{}",
1302        passing.to_string().green(),
1303        format!("/{}", total).bright_white()
1304    )
1305}
1306
1307fn color_service_status(status: &str) -> colored::ColoredString {
1308    let lower = status.to_ascii_lowercase();
1309    if lower.contains("running") || lower.contains("active") || lower.contains("online") {
1310        status.green()
1311    } else if lower.contains("stopped") || lower.contains("failed") || lower.contains("error") {
1312        status.red()
1313    } else if lower.contains("degraded") || lower.contains("warn") {
1314        status.yellow()
1315    } else {
1316        status.bright_white()
1317    }
1318}
1319
1320fn render_feature_flags(flags: &HashMap<String, bool>) -> String {
1321    let mut items = flags
1322        .iter()
1323        .map(|(name, value)| format!("{}={}", name, diag_bool(*value)))
1324        .collect::<Vec<_>>();
1325    items.sort();
1326    items.join("  ")
1327}
1328
1329fn summarize_path_permissions(report: &DiagnosticReport) -> (usize, usize) {
1330    let total = report.path_permission_checks.len();
1331    let passing = report
1332        .path_permission_checks
1333        .iter()
1334        .filter(|item| item.ok)
1335        .count();
1336    (passing, total)
1337}
1338
1339fn summarize_port_checks(report: &DiagnosticReport) -> (usize, usize, usize) {
1340    let total = report.port_checks.len();
1341    let open = report
1342        .port_checks
1343        .iter()
1344        .filter(|item| item.is_open)
1345        .count();
1346    let blocked = report
1347        .port_checks
1348        .iter()
1349        .filter(|item| item.is_blocked)
1350        .count();
1351    (open, blocked, total)
1352}
1353
1354pub async fn print_diagnostic_report(report: &DiagnosticReport) {
1355    let feature_flags = render_feature_flags(&report.feature_flags);
1356    let env_summary = format!(
1357        "v{} | {} | connectivity {}",
1358        report.xbp_cli_version,
1359        feature_flags,
1360        if report.connectivity {
1361            "online"
1362        } else {
1363            "offline"
1364        }
1365    );
1366    print_section_header(
1367        "Environment",
1368        if report.connectivity {
1369            SectionState::Healthy
1370        } else {
1371            SectionState::Warn
1372        },
1373        &env_summary,
1374    );
1375
1376    println!(
1377        "  {} {}",
1378        "XBP version:".bright_white(),
1379        report.xbp_cli_version.bright_cyan()
1380    );
1381    println!("  {} {}", "Feature flags:".bright_white(), feature_flags);
1382
1383    if let Some(os) = &report.os {
1384        let os_line = [
1385            os.name.clone().unwrap_or_else(|| "Unknown OS".to_string()),
1386            os.version.clone().unwrap_or_default(),
1387        ]
1388        .into_iter()
1389        .filter(|s| !s.is_empty())
1390        .collect::<Vec<_>>()
1391        .join(" ");
1392        println!("  {} {}", "OS:".bright_white(), os_line);
1393        if let Some(kernel) = &os.kernel_version {
1394            println!("  {} {}", "Kernel:".bright_white(), kernel);
1395        }
1396        if let Some(arch) = &os.arch {
1397            println!("  {} {}", "Arch:".bright_white(), arch);
1398        }
1399    }
1400
1401    if let Some(cpu) = &report.cpu {
1402        println!(
1403            "  {} {} ({} cores, {:.1}% avg)",
1404            "CPU:".bright_white(),
1405            cpu.brand.clone().unwrap_or_else(|| "Unknown".into()),
1406            cpu.cores,
1407            cpu.usage
1408        );
1409    }
1410
1411    if let Some(shell) = &report.shell {
1412        if let Some(s) = &shell.shell {
1413            println!("  {} {}", "Shell:".bright_white(), s);
1414        }
1415        if let Some(t) = &shell.term {
1416            println!("  {} {}", "TERM:".bright_white(), t);
1417        }
1418    }
1419
1420    println!(
1421        "  {} {}",
1422        "Connectivity:".bright_white(),
1423        if report.connectivity {
1424            "online".green()
1425        } else {
1426            "offline".red()
1427        }
1428    );
1429
1430    if let Some(speed) = &report.internet_speed {
1431        println!("  {} {:.2} ms", "Ping:".bright_white(), speed.ping_ms);
1432        println!(
1433            "  {} {:.2} Mbps",
1434            "Download:".bright_white(),
1435            speed.download_mbps
1436        );
1437        if speed.upload_mbps > 0.0 {
1438            println!(
1439                "  {} {:.2} Mbps",
1440                "Upload:".bright_white(),
1441                speed.upload_mbps
1442            );
1443        }
1444    }
1445
1446    if !report.gpu_candidates.is_empty() {
1447        print_section_header(
1448            "GPU candidates",
1449            SectionState::Info,
1450            &format!("{} candidate(s)", report.gpu_candidates.len()),
1451        );
1452        for line in &report.gpu_candidates {
1453            println!("  {}", line);
1454        }
1455    }
1456
1457    if !report.disks.is_empty() {
1458        print_section_header(
1459            "Disks",
1460            SectionState::Info,
1461            &format!("{} mount(s)", report.disks.len()),
1462        );
1463        for disk in &report.disks {
1464            let current = if disk.is_current { " *" } else { "" };
1465            let fs = disk.fs.clone().unwrap_or_else(|| "-".to_string());
1466            println!(
1467                "  {}{}  {} {:>5.1}%  {} ({} / {} GB)",
1468                disk.mount,
1469                current,
1470                fs,
1471                disk.percent,
1472                disk.bar,
1473                disk.used / 1024 / 1024 / 1024,
1474                disk.total / 1024 / 1024 / 1024
1475            );
1476        }
1477        println!("  {} current mount", "*".bright_cyan());
1478    }
1479
1480    print_section_header(
1481        "System metrics",
1482        SectionState::Info,
1483        &format!("{} processes", report.system_metrics.process_count),
1484    );
1485    let metrics = &report.system_metrics;
1486
1487    let cpu_color = if metrics.cpu_usage > 80.0 {
1488        "red"
1489    } else if metrics.cpu_usage > 50.0 {
1490        "yellow"
1491    } else {
1492        "green"
1493    };
1494    println!(
1495        "  {} {:.1}%",
1496        "CPU usage:".bright_white(),
1497        format!("{}", metrics.cpu_usage).color(cpu_color)
1498    );
1499
1500    let mem_color = if metrics.memory_percent > 80.0 {
1501        "red"
1502    } else if metrics.memory_percent > 50.0 {
1503        "yellow"
1504    } else {
1505        "green"
1506    };
1507    println!(
1508        "  {} {:.1}% ({} MB / {} MB)",
1509        "Memory:".bright_white(),
1510        format!("{}", metrics.memory_percent).color(mem_color),
1511        metrics.memory_used / 1024 / 1024,
1512        metrics.memory_total / 1024 / 1024
1513    );
1514
1515    let disk_color = if metrics.disk_percent > 80.0 {
1516        "red"
1517    } else if metrics.disk_percent > 50.0 {
1518        "yellow"
1519    } else {
1520        "green"
1521    };
1522    println!(
1523        "  {} {:.1}% ({} GB / {} GB)",
1524        "Disk:".bright_white(),
1525        format!("{}", metrics.disk_percent).color(disk_color),
1526        metrics.disk_used / 1024 / 1024 / 1024,
1527        metrics.disk_total / 1024 / 1024 / 1024
1528    );
1529
1530    println!(
1531        "  {} {} MB down / {} MB up",
1532        "Network:".bright_white(),
1533        metrics.network_rx / 1024 / 1024,
1534        metrics.network_tx / 1024 / 1024
1535    );
1536
1537    let uptime_hours = metrics.uptime / 3600;
1538    let uptime_minutes = (metrics.uptime % 3600) / 60;
1539    println!(
1540        "  {} {}h {}m",
1541        "Uptime:".bright_white(),
1542        uptime_hours,
1543        uptime_minutes
1544    );
1545    println!(
1546        "  {} {}",
1547        "Processes:".bright_white(),
1548        metrics.process_count
1549    );
1550
1551    let (open_ports, blocked_ports, _) = summarize_port_checks(report);
1552    let runtime_state = if blocked_ports == 0 {
1553        SectionState::Healthy
1554    } else {
1555        SectionState::Fail
1556    };
1557    print_section_header(
1558        "Runtime",
1559        runtime_state,
1560        &format!(
1561            "services={} | pm2={} | ports={} open / {} blocked",
1562            report.service_statuses.len(),
1563            report.pm2_process_count.unwrap_or(0),
1564            open_ports,
1565            blocked_ports
1566        ),
1567    );
1568
1569    if let Some(proxy) = &report.proxy_detection {
1570        println!("  {}", "Proxy stack:".bright_white());
1571        if let Some(n) = proxy.nginx_sites_available {
1572            println!("    {} {}", "sites-available:".bright_white(), n);
1573        }
1574        if let Some(v) = proxy.nginx_active {
1575            println!("    {} {}", "nginx active:".bright_white(), diag_bool(v));
1576        }
1577        if let Some(v) = proxy.traefik_active {
1578            println!("    {} {}", "traefik active:".bright_white(), diag_bool(v));
1579        }
1580        if let Some(v) = proxy.apache_active {
1581            println!("    {} {}", "apache active:".bright_white(), diag_bool(v));
1582        }
1583    }
1584
1585    if let Some(count) = report.pm2_process_count {
1586        println!("  {} {}", "PM2 process count:".bright_white(), count);
1587    }
1588
1589    if !report.service_statuses.is_empty() {
1590        println!("  {}", "Services:".bright_white());
1591        let mut items: Vec<_> = report.service_statuses.iter().collect();
1592        items.sort_by_key(|(k, _)| *k);
1593        for (name, status) in items {
1594            println!("    {:15} {}", name, color_service_status(status));
1595        }
1596    }
1597
1598    if let Some(nginx) = &report.nginx_status {
1599        println!("  {}", "Nginx:".bright_white());
1600        println!(
1601            "    {} {}",
1602            if nginx.is_running {
1603                "OK".green()
1604            } else {
1605                "NO".red()
1606            },
1607            if nginx.is_running {
1608                "Running".green()
1609            } else {
1610                "Stopped".red()
1611            }
1612        );
1613        println!(
1614            "    {} {}",
1615            if nginx.is_enabled {
1616                "OK".green()
1617            } else {
1618                "NO".red()
1619            },
1620            if nginx.is_enabled {
1621                "Enabled".green()
1622            } else {
1623                "Disabled".red()
1624            }
1625        );
1626        println!(
1627            "    {} {}",
1628            if nginx.config_valid {
1629                "OK".green()
1630            } else {
1631                "NO".red()
1632            },
1633            if nginx.config_valid {
1634                "Config valid".green()
1635            } else {
1636                "Config invalid".red()
1637            }
1638        );
1639    }
1640
1641    if !report.port_checks.is_empty() {
1642        println!("  {}", "Port coverage:".bright_white());
1643        let (xbp_ports, other_ports): (Vec<_>, Vec<_>) = report
1644            .port_checks
1645            .iter()
1646            .partition(|port_check| !port_check.xbp_projects.is_empty());
1647
1648        for port_check in xbp_ports.iter().chain(other_ports.iter()) {
1649            let status_icon = if port_check.is_open {
1650                "OK".green()
1651            } else {
1652                "NO".red()
1653            };
1654            let blocked_text = if port_check.is_blocked {
1655                " (BLOCKED)".red().to_string()
1656            } else {
1657                String::new()
1658            };
1659            let xbp_suffix = if port_check.xbp_projects.is_empty() {
1660                String::new()
1661            } else {
1662                format!(" [XBP: {}]", port_check.xbp_projects.join(", "))
1663            };
1664            let line = format!(
1665                "    {} Port {}: {}{}{}",
1666                status_icon,
1667                port_check.port,
1668                if port_check.is_open {
1669                    "Available".green().to_string()
1670                } else {
1671                    "In use".red().to_string()
1672                },
1673                blocked_text,
1674                xbp_suffix
1675            );
1676
1677            if port_check.xbp_projects.is_empty() {
1678                println!("{}", line);
1679            } else {
1680                println!("{}", line.bright_magenta());
1681            }
1682        }
1683    }
1684
1685    let (path_passing, path_total) = summarize_path_permissions(report);
1686    let project_state = if report.project_config_present || !report.provider_manifests.is_empty() {
1687        if path_passing == path_total {
1688            SectionState::Healthy
1689        } else {
1690            SectionState::Warn
1691        }
1692    } else {
1693        SectionState::Info
1694    };
1695    print_section_header(
1696        "Project",
1697        project_state,
1698        &format!(
1699            "config={} | services={} | manifests={} | paths={}",
1700            diag_bool(report.project_config_present),
1701            report.configured_services.len(),
1702            report.provider_manifests.len(),
1703            diag_ratio(path_passing, path_total)
1704        ),
1705    );
1706
1707    if !report.configured_services.is_empty() {
1708        println!(
1709            "  {} {}",
1710            "Configured services:".bright_white(),
1711            report.configured_services.join(", ").bright_white()
1712        );
1713    }
1714
1715    if !report.provider_manifests.is_empty() {
1716        println!(
1717            "  {} {}",
1718            "Provider manifests:".bright_white(),
1719            report.provider_manifests.join(", ").bright_white()
1720        );
1721    }
1722
1723    if !report.path_permission_checks.is_empty() {
1724        println!(
1725            "  {} {}",
1726            "Checks passing:".bright_white(),
1727            diag_ratio(path_passing, path_total)
1728        );
1729
1730        for item in &report.path_permission_checks {
1731            let icon = if item.ok { "OK".green() } else { "NO".red() };
1732            println!(
1733                "    {} {} [{}]",
1734                icon,
1735                item.path.bright_white(),
1736                item.required
1737            );
1738            println!("      purpose: {}", item.purpose);
1739            println!(
1740                "      status: {} {} {} {}",
1741                diag_bool_fragment("exists", item.exists),
1742                diag_bool_fragment("read", item.readable),
1743                diag_bool_fragment("write", item.writable),
1744                diag_bool_fragment("create", item.creatable)
1745            );
1746            if let Some(msg) = &item.message {
1747                println!("      note: {}", msg);
1748            }
1749        }
1750    }
1751
1752    print_section_header(
1753        "XBP readiness",
1754        if report.project_config_present {
1755            SectionState::Healthy
1756        } else {
1757            SectionState::Warn
1758        },
1759        &format!(
1760            "version={} | feature flags={} | manifests={}",
1761            report.xbp_cli_version,
1762            report.feature_flags.len(),
1763            report.provider_manifests.len()
1764        ),
1765    );
1766    println!(
1767        "  {} {}",
1768        "CLI version:".bright_white(),
1769        report.xbp_cli_version.bright_cyan()
1770    );
1771    println!("  {} {}", "Feature flags:".bright_white(), feature_flags);
1772    println!(
1773        "  {} {}",
1774        "Project config:".bright_white(),
1775        if report.project_config_present {
1776            "detected".green()
1777        } else {
1778            "missing".red()
1779        }
1780    );
1781    println!(
1782        "  {} {}",
1783        "Provider manifests:".bright_white(),
1784        report.provider_manifests.len()
1785    );
1786
1787    if let Some(codetime) = &report.codetime {
1788        print_section_header(
1789            "CodeTime inventory",
1790            if codetime.refreshed {
1791                SectionState::Healthy
1792            } else {
1793                SectionState::Info
1794            },
1795            &format!(
1796                "saved={} | refreshed={}",
1797                codetime.saved_config_path,
1798                diag_bool(codetime.refreshed)
1799            ),
1800        );
1801        println!(
1802            "  {} {}",
1803            "Saved in:".bright_white(),
1804            codetime.saved_config_path
1805        );
1806        println!(
1807            "  {} {}",
1808            "Refreshed:".bright_white(),
1809            if codetime.refreshed {
1810                "cached"
1811            } else {
1812                "fresh"
1813            }
1814        );
1815        if let Some(user) = &codetime.inventory.system_user {
1816            println!("  {} {}", "User:".bright_white(), user);
1817        }
1818        if let Some(host) = &codetime.inventory.host_name {
1819            println!("  {} {}", "Host:".bright_white(), host);
1820        }
1821        println!(
1822            "  {} {} / {}",
1823            "Platform:".bright_white(),
1824            codetime.inventory.platform,
1825            codetime.inventory.architecture
1826        );
1827        if let Some(os) = &codetime.inventory.operating_system {
1828            let os_label = os
1829                .long_version
1830                .clone()
1831                .or_else(|| match (&os.name, &os.version) {
1832                    (Some(name), Some(version)) => Some(format!("{} {}", name, version)),
1833                    (Some(name), None) => Some(name.clone()),
1834                    (None, Some(version)) => Some(version.clone()),
1835                    (None, None) => None,
1836                });
1837            if let Some(os_label) = os_label {
1838                println!("  {} {}", "Operating system:".bright_white(), os_label);
1839            }
1840            if let Some(kernel_version) = &os.kernel_version {
1841                println!("  {} {}", "Kernel:".bright_white(), kernel_version);
1842            }
1843        }
1844        if !codetime.inventory.drives.is_empty() {
1845            let drives = codetime
1846                .inventory
1847                .drives
1848                .iter()
1849                .map(|drive| match &drive.file_system {
1850                    Some(file_system) => format!("{} ({})", drive.mount, file_system),
1851                    None => drive.mount.clone(),
1852                })
1853                .collect::<Vec<_>>()
1854                .join(", ");
1855            println!("  {} {}", "Drives:".bright_white(), drives);
1856        }
1857        if let Some(home) = codetime.inventory.standard_paths.get("home") {
1858            println!("  {} {}", "Home:".bright_white(), home);
1859        }
1860        if let Some(xbp_root) = codetime.inventory.standard_paths.get("xbp_global_root") {
1861            println!("  {} {}", "XBP root:".bright_white(), xbp_root);
1862        }
1863        if let Some(cursor_root) = codetime.inventory.standard_paths.get("cursor_roaming") {
1864            println!("  {} {}", "Cursor root:".bright_white(), cursor_root);
1865        }
1866
1867        let installed_tool_labels = codetime
1868            .inventory
1869            .installed_tools
1870            .iter()
1871            .filter(|tool| tool.present)
1872            .map(|tool| match &tool.version {
1873                Some(version) => format!("{} ({})", tool.name, version),
1874                None => tool.name.clone(),
1875            })
1876            .collect::<Vec<_>>();
1877        println!(
1878            "  {} {}",
1879            "Installed tools:".bright_white(),
1880            if installed_tool_labels.is_empty() {
1881                "0".to_string()
1882            } else {
1883                format!(
1884                    "{} ({})",
1885                    installed_tool_labels.len(),
1886                    format_sample_list(&installed_tool_labels, 8)
1887                )
1888            }
1889        );
1890
1891        let identity_labels = codetime
1892            .inventory
1893            .git_identities
1894            .iter()
1895            .map(
1896                |identity| match (&identity.user_name, &identity.user_email) {
1897                    (Some(name), Some(email)) => format!("{} <{}>", name, email),
1898                    (Some(name), None) => name.clone(),
1899                    (None, Some(email)) => email.clone(),
1900                    (None, None) => identity.scope.clone(),
1901                },
1902            )
1903            .collect::<Vec<_>>();
1904        if !identity_labels.is_empty() {
1905            println!(
1906                "  {} {}",
1907                "Git identities:".bright_white(),
1908                format_sample_list(&identity_labels, 4)
1909            );
1910        }
1911
1912        let repo_names = codetime
1913            .inventory
1914            .github_repos
1915            .iter()
1916            .map(|repo| repo.full_name.clone())
1917            .collect::<Vec<_>>();
1918        println!(
1919            "  {} {}",
1920            "Seen GitHub repos:".bright_white(),
1921            if repo_names.is_empty() {
1922                "0".to_string()
1923            } else {
1924                format!(
1925                    "{} ({})",
1926                    repo_names.len(),
1927                    format_sample_list(&repo_names, 5)
1928                )
1929            }
1930        );
1931
1932        let project_names = codetime
1933            .inventory
1934            .xbp_projects
1935            .iter()
1936            .map(|project| project.name.clone())
1937            .collect::<Vec<_>>();
1938        println!(
1939            "  {} {}",
1940            "Seen XBP projects:".bright_white(),
1941            if project_names.is_empty() {
1942                "0".to_string()
1943            } else {
1944                format!(
1945                    "{} ({})",
1946                    project_names.len(),
1947                    format_sample_list(&project_names, 5)
1948                )
1949            }
1950        );
1951
1952        if let Some(cursor) = &codetime.inventory.cursor {
1953            println!(
1954                "  {} {}",
1955                "Cursor present:".bright_white(),
1956                diag_bool(cursor.exists)
1957            );
1958            println!(
1959                "  {} workspaceStorage={} extensions={} logs={}",
1960                "Cursor details:".bright_white(),
1961                cursor.workspace_storage_entries,
1962                cursor.extensions_entries,
1963                diag_bool(cursor.logs_exists)
1964            );
1965            if let Some(note) = &cursor.note {
1966                println!("  {} {}", "Cursor note:".bright_white(), note);
1967            }
1968        }
1969    }
1970
1971    if !report.clipboard_tools.is_empty() {
1972        println!("\n{}", "Clipboard tools".bright_yellow().bold());
1973        let mut items: Vec<_> = report.clipboard_tools.iter().collect();
1974        items.sort_by_key(|(k, _)| *k);
1975        for (name, value) in items {
1976            println!("  {:10} {}", name, diag_bool(*value));
1977        }
1978    }
1979
1980    if !report.installed_programs.is_empty() {
1981        print_section_header(
1982            "Installed programs",
1983            SectionState::Info,
1984            &format!("{} program(s)", report.installed_programs.len()),
1985        );
1986
1987        let mut programs: Vec<_> = report.installed_programs.iter().collect();
1988        programs.sort_by_key(|(name, _)| *name);
1989        for (program, installed) in programs {
1990            let status_icon = if *installed { "OK".green() } else { "NO".red() };
1991            let status_text = if *installed {
1992                "Installed".green()
1993            } else {
1994                "Missing".red()
1995            };
1996            println!("  {} {:15} {}", status_icon, program, status_text);
1997        }
1998    }
1999
2000    if !report.tool_versions.is_empty() {
2001        print_section_header(
2002            "Tool versions",
2003            SectionState::Info,
2004            &format!("{} tool(s)", report.tool_versions.len()),
2005        );
2006        let mut items: Vec<_> = report.tool_versions.iter().collect();
2007        items.sort_by_key(|(k, _)| *k);
2008        for (name, v) in items {
2009            if v.present {
2010                println!(
2011                    "  {:12} {}",
2012                    name,
2013                    v.version
2014                        .clone()
2015                        .unwrap_or_else(|| "present".to_string())
2016                        .green()
2017                );
2018            } else {
2019                println!("  {:12} {}", name, "not found".red());
2020            }
2021        }
2022    }
2023}
2024fn format_sample_list(items: &[String], limit: usize) -> String {
2025    if items.is_empty() {
2026        return String::new();
2027    }
2028
2029    let shown = items.iter().take(limit).cloned().collect::<Vec<_>>();
2030    let remaining = items.len().saturating_sub(shown.len());
2031    if remaining == 0 {
2032        shown.join(", ")
2033    } else {
2034        format!("{} +{} more", shown.join(", "), remaining)
2035    }
2036}
2037
2038fn format_eta(duration: Duration) -> String {
2039    let total_seconds = duration.as_secs();
2040    let minutes = total_seconds / 60;
2041    let seconds = total_seconds % 60;
2042    if minutes > 0 {
2043        format!("{}m {:02}s", minutes, seconds)
2044    } else {
2045        format!("{}s", seconds)
2046    }
2047}
2048
2049fn estimate_diag_timeout_budget(
2050    ports_count: usize,
2051    skip_speed_test: bool,
2052    include_codetime: bool,
2053) -> Duration {
2054    // This is a conservative upper bound shown to operators while diagnostics runs.
2055    let fixed_seconds =
2056        2 + 2 + 2 + 2 + 3 + 3 + 10 + 4 + PM2_DIAG_TIMEOUT.as_secs() + NGINX_CHECK_TIMEOUT.as_secs();
2057    let port_seconds =
2058        PORT_OWNERSHIP_TIMEOUT.as_secs() + (ports_count as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
2059    let speed_seconds = if skip_speed_test {
2060        0
2061    } else {
2062        INTERNET_SPEED_TIMEOUT.as_secs()
2063    };
2064    let codetime_seconds = if include_codetime {
2065        SYSTEM_INVENTORY_TIMEOUT.as_secs()
2066    } else {
2067        0
2068    };
2069    Duration::from_secs(fixed_seconds + port_seconds + speed_seconds + codetime_seconds)
2070}
2071
2072fn set_step_message(
2073    pb: &ProgressBar,
2074    step_index: usize,
2075    step_total: usize,
2076    started_at: Instant,
2077    estimated_budget: Duration,
2078    label: &str,
2079    timeout: Option<Duration>,
2080) {
2081    let elapsed = started_at.elapsed();
2082    let remaining = estimated_budget.saturating_sub(elapsed);
2083    let timeout_suffix = timeout
2084        .map(|value| format!(" | timeout {}s", value.as_secs()))
2085        .unwrap_or_default();
2086    pb.set_message(format!(
2087        "[{}/{}] {} | ETA {}{}",
2088        step_index,
2089        step_total,
2090        label,
2091        format_eta(remaining),
2092        timeout_suffix
2093    ));
2094}
2095
2096async fn collect_port_ownership_with_timeout() -> BTreeMap<u16, ListeningPortOwnership> {
2097    let task = tokio::task::spawn_blocking(collect_listening_port_ownership);
2098    match tokio::time::timeout(PORT_OWNERSHIP_TIMEOUT, task).await {
2099        Ok(Ok(Ok(ports))) => ports,
2100        Ok(Ok(Err(err))) => {
2101            let _ = log_warn("diag", "Port ownership scan failed", Some(err.as_str())).await;
2102            BTreeMap::new()
2103        }
2104        Ok(Err(err)) => {
2105            let message = format!("Port ownership scan task failed: {}", err);
2106            let _ = log_warn("diag", "Port ownership scan failed", Some(message.as_str())).await;
2107            BTreeMap::new()
2108        }
2109        Err(_) => {
2110            let timeout_note = format!(
2111                "Port ownership scan timed out after {} seconds",
2112                PORT_OWNERSHIP_TIMEOUT.as_secs()
2113            );
2114            let _ = log_warn(
2115                "diag",
2116                "Port ownership scan timed out",
2117                Some(timeout_note.as_str()),
2118            )
2119            .await;
2120            BTreeMap::new()
2121        }
2122    }
2123}
2124
2125async fn sync_system_inventory_with_timeout(include_cursor: bool) -> Option<CodeTimeDiagnostic> {
2126    let current_dir = env::current_dir().ok();
2127    let task = tokio::task::spawn_blocking(move || {
2128        sync_system_inventory(true, include_cursor, current_dir.as_deref())
2129    });
2130
2131    match tokio::time::timeout(SYSTEM_INVENTORY_TIMEOUT, task).await {
2132        Ok(Ok(Ok(result))) => Some(CodeTimeDiagnostic {
2133            saved_config_path: result.config_path.display().to_string(),
2134            refreshed: result.refreshed,
2135            inventory: result.inventory,
2136        }),
2137        Ok(Ok(Err(err))) => {
2138            let _ = log_warn("diag", "System inventory sync failed", Some(err.as_str())).await;
2139            None
2140        }
2141        Ok(Err(err)) => {
2142            let message = format!("System inventory task failed: {}", err);
2143            let _ = log_warn(
2144                "diag",
2145                "System inventory sync failed",
2146                Some(message.as_str()),
2147            )
2148            .await;
2149            None
2150        }
2151        Err(_) => {
2152            let message = format!(
2153                "System inventory sync timed out after {} seconds",
2154                SYSTEM_INVENTORY_TIMEOUT.as_secs()
2155            );
2156            let _ = log_warn(
2157                "diag",
2158                "System inventory sync timed out",
2159                Some(message.as_str()),
2160            )
2161            .await;
2162            None
2163        }
2164    }
2165}
2166
2167pub async fn run_full_diagnostics(
2168    ports: Vec<u16>,
2169    options: DiagnosticRunOptions,
2170) -> Result<DiagnosticReport> {
2171    let _ = log_info("diag", "Running system diagnostics...", None).await;
2172
2173    let estimated_budget =
2174        estimate_diag_timeout_budget(ports.len(), options.skip_speed_test, options.codetime);
2175    let timeout_summary = format!(
2176        "Estimated max runtime {} (PM2 {}s, ports {}s + {}s/port{}{})",
2177        format_eta(estimated_budget),
2178        PM2_DIAG_TIMEOUT.as_secs(),
2179        PORT_OWNERSHIP_TIMEOUT.as_secs(),
2180        PER_PORT_CHECK_TIMEOUT.as_secs(),
2181        if options.skip_speed_test {
2182            ", speed test skipped"
2183        } else {
2184            ", speed test up to 20s"
2185        },
2186        if options.codetime {
2187            ", codetime inventory up to 25s"
2188        } else {
2189            ""
2190        }
2191    );
2192    let _ = log_info(
2193        "diag",
2194        "Diagnostics timeout budget",
2195        Some(timeout_summary.as_str()),
2196    )
2197    .await;
2198
2199    let pb: ProgressBar = ProgressBar::new_spinner();
2200    pb.enable_steady_tick(Duration::from_millis(80));
2201    pb.set_style(
2202        ProgressStyle::with_template("{spinner} {msg}")
2203            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
2204    );
2205    let started_at = Instant::now();
2206    let step_total = if options.codetime { 14 } else { 13 };
2207    let mut step_index = 1usize;
2208
2209    set_step_message(
2210        &pb,
2211        step_index,
2212        step_total,
2213        started_at,
2214        estimated_budget,
2215        "Collecting system metrics",
2216        None,
2217    );
2218    step_index += 1;
2219    let system_metrics = get_system_metrics().await?;
2220
2221    set_step_message(
2222        &pb,
2223        step_index,
2224        step_total,
2225        started_at,
2226        estimated_budget,
2227        "Collecting OS/CPU/GPU",
2228        None,
2229    );
2230    step_index += 1;
2231    let os = get_os_info().await;
2232    let cpu = get_cpu_info().await;
2233    let gpu_candidates = get_gpu_candidates().await;
2234
2235    set_step_message(
2236        &pb,
2237        step_index,
2238        step_total,
2239        started_at,
2240        estimated_budget,
2241        "Collecting disks",
2242        None,
2243    );
2244    step_index += 1;
2245    let disks = get_disk_infos().await;
2246
2247    let codetime = if options.codetime {
2248        set_step_message(
2249            &pb,
2250            step_index,
2251            step_total,
2252            started_at,
2253            estimated_budget,
2254            if options.cursor {
2255                "Refreshing CodeTime + Cursor inventory"
2256            } else {
2257                "Refreshing CodeTime inventory"
2258            },
2259            Some(SYSTEM_INVENTORY_TIMEOUT),
2260        );
2261        step_index += 1;
2262        sync_system_inventory_with_timeout(options.cursor).await
2263    } else {
2264        None
2265    };
2266
2267    set_step_message(
2268        &pb,
2269        step_index,
2270        step_total,
2271        started_at,
2272        estimated_budget,
2273        "Collecting shell + clipboard",
2274        None,
2275    );
2276    step_index += 1;
2277    let shell = get_shell_info();
2278    let clipboard_tools = get_clipboard_tools().await;
2279
2280    set_step_message(
2281        &pb,
2282        step_index,
2283        step_total,
2284        started_at,
2285        estimated_budget,
2286        "Detecting proxies",
2287        None,
2288    );
2289    step_index += 1;
2290    let proxy_detection = get_proxy_detection().await;
2291
2292    set_step_message(
2293        &pb,
2294        step_index,
2295        step_total,
2296        started_at,
2297        estimated_budget,
2298        "Checking network/public IP",
2299        None,
2300    );
2301    step_index += 1;
2302    let connectivity = check_internet_connectivity().await.unwrap_or(false);
2303    let exposure = get_exposure_info(connectivity).await;
2304
2305    set_step_message(
2306        &pb,
2307        step_index,
2308        step_total,
2309        started_at,
2310        estimated_budget,
2311        "Checking tools",
2312        Some(TOOL_VERSION_TIMEOUT),
2313    );
2314    step_index += 1;
2315    let installed_programs = check_installed_programs().await;
2316    let tool_versions = get_tool_versions().await;
2317
2318    set_step_message(
2319        &pb,
2320        step_index,
2321        step_total,
2322        started_at,
2323        estimated_budget,
2324        "Checking file permissions",
2325        None,
2326    );
2327    step_index += 1;
2328    let path_permission_checks = get_path_permission_checks().await;
2329
2330    set_step_message(
2331        &pb,
2332        step_index,
2333        step_total,
2334        started_at,
2335        estimated_budget,
2336        "Checking services",
2337        None,
2338    );
2339    step_index += 1;
2340    let service_statuses = get_service_statuses().await;
2341
2342    set_step_message(
2343        &pb,
2344        step_index,
2345        step_total,
2346        started_at,
2347        estimated_budget,
2348        "Checking PM2 process table",
2349        Some(PM2_DIAG_TIMEOUT),
2350    );
2351    step_index += 1;
2352    let pm2_process_count = get_pm2_process_count().await;
2353
2354    let ports_timeout_budget = PORT_OWNERSHIP_TIMEOUT
2355        + Duration::from_secs(ports.len() as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
2356    set_step_message(
2357        &pb,
2358        step_index,
2359        step_total,
2360        started_at,
2361        estimated_budget,
2362        "Checking ports",
2363        Some(ports_timeout_budget),
2364    );
2365    step_index += 1;
2366    let port_ownership = collect_port_ownership_with_timeout().await;
2367
2368    let mut port_checks = Vec::new();
2369    for port in ports {
2370        let timed_check = tokio::time::timeout(
2371            PER_PORT_CHECK_TIMEOUT,
2372            check_port_availability(port, port_ownership.get(&port)),
2373        )
2374        .await;
2375        match timed_check {
2376            Ok(Ok(check)) => port_checks.push(check),
2377            Ok(Err(err)) => {
2378                let details = format!("Port {} check failed: {}", port, err);
2379                let _ = log_warn("diag", "Port check failed", Some(details.as_str())).await;
2380            }
2381            Err(_) => {
2382                let details = format!(
2383                    "Port {} check timed out after {} seconds",
2384                    port,
2385                    PER_PORT_CHECK_TIMEOUT.as_secs()
2386                );
2387                let _ = log_warn("diag", "Port check timed out", Some(details.as_str())).await;
2388            }
2389        }
2390    }
2391
2392    set_step_message(
2393        &pb,
2394        step_index,
2395        step_total,
2396        started_at,
2397        estimated_budget,
2398        "Measuring internet speed",
2399        Some(INTERNET_SPEED_TIMEOUT),
2400    );
2401    step_index += 1;
2402    let internet_speed = if options.skip_speed_test {
2403        let _ = log_info(
2404            "diag",
2405            "Skipping internet speed test (--no-speed-test)",
2406            None,
2407        )
2408        .await;
2409        None
2410    } else if connectivity {
2411        match tokio::time::timeout(INTERNET_SPEED_TIMEOUT, measure_internet_speed()).await {
2412            Ok(Ok(speed)) => Some(speed),
2413            Ok(Err(err)) => {
2414                let details = format!("Internet speed probe failed: {}", err);
2415                let _ = log_warn(
2416                    "diag",
2417                    "Internet speed probe failed",
2418                    Some(details.as_str()),
2419                )
2420                .await;
2421                None
2422            }
2423            Err(_) => {
2424                let details = format!(
2425                    "Internet speed probe timed out after {} seconds",
2426                    INTERNET_SPEED_TIMEOUT.as_secs()
2427                );
2428                let _ = log_warn(
2429                    "diag",
2430                    "Internet speed probe timed out",
2431                    Some(details.as_str()),
2432                )
2433                .await;
2434                None
2435            }
2436        }
2437    } else {
2438        None
2439    };
2440
2441    set_step_message(
2442        &pb,
2443        step_index,
2444        step_total,
2445        started_at,
2446        estimated_budget,
2447        "Checking nginx",
2448        Some(NGINX_CHECK_TIMEOUT),
2449    );
2450    let nginx_status = match tokio::time::timeout(NGINX_CHECK_TIMEOUT, check_nginx_status()).await {
2451        Ok(Ok(status)) => Some(status),
2452        Ok(Err(err)) => {
2453            let details = format!("Nginx status check failed: {}", err);
2454            let _ = log_warn("diag", "Nginx status check failed", Some(details.as_str())).await;
2455            None
2456        }
2457        Err(_) => {
2458            let details = format!(
2459                "Nginx status check timed out after {} seconds",
2460                NGINX_CHECK_TIMEOUT.as_secs()
2461            );
2462            let _ = log_warn(
2463                "diag",
2464                "Nginx status check timed out",
2465                Some(details.as_str()),
2466            )
2467            .await;
2468            None
2469        }
2470    };
2471
2472    let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2473    let config = load_xbp_config().await.ok();
2474    let project_config_present = config.is_some();
2475    let configured_services = config
2476        .as_ref()
2477        .map(|cfg| {
2478            get_all_services(cfg)
2479                .into_iter()
2480                .map(|service| format!("{}:{}", service.name, service.port))
2481                .collect::<Vec<_>>()
2482        })
2483        .unwrap_or_default();
2484    let provider_manifests: Vec<String> = ProjectDetector::detect_provider_manifests(&current_dir);
2485
2486    let mut feature_flags: HashMap<String, bool> = HashMap::new();
2487    feature_flags.insert("monitoring".to_string(), cfg!(feature = "monitoring"));
2488    feature_flags.insert(
2489        "kafka".to_string(),
2490        cfg!(all(feature = "kafka", not(windows))),
2491    );
2492    feature_flags.insert("kubernetes".to_string(), cfg!(feature = "kubernetes"));
2493    feature_flags.insert("docker".to_string(), cfg!(feature = "docker"));
2494
2495    let xbp_cli_version = env!("CARGO_PKG_VERSION").to_string();
2496
2497    pb.finish_and_clear();
2498
2499    Ok(DiagnosticReport {
2500        system_metrics,
2501        os,
2502        cpu,
2503        gpu_candidates,
2504        disks,
2505        shell,
2506        proxy_detection,
2507        clipboard_tools,
2508        exposure,
2509        pm2_process_count,
2510        tool_versions,
2511        service_statuses,
2512        feature_flags,
2513        xbp_cli_version,
2514        project_config_present,
2515        configured_services,
2516        provider_manifests,
2517        nginx_status,
2518        port_checks,
2519        internet_speed,
2520        connectivity,
2521        installed_programs,
2522        path_permission_checks,
2523        codetime,
2524    })
2525}