Skip to main content

xbp_cli/commands/
system_diag.rs

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