Skip to main content

xbp_cli/commands/
system_diag.rs

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