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, Clone)]
123pub struct DnsCheck {
124    pub host: String,
125    pub ok: bool,
126    pub addresses: Vec<String>,
127    pub error: Option<String>,
128}
129
130#[derive(Debug, Serialize, Deserialize, Clone)]
131pub struct HttpProbe {
132    pub name: String,
133    pub url: String,
134    pub ok: bool,
135    pub status: Option<u16>,
136    pub latency_ms: Option<u64>,
137    pub error: Option<String>,
138}
139
140/// Git credential.helper diagnostic (stale Snap `gh` paths on Linux/WSL).
141#[derive(Debug, Serialize, Deserialize, Clone, Default)]
142pub struct GitCredentialHelperDiag {
143    /// All configured credential.helper values (may be empty).
144    pub helpers: Vec<String>,
145    /// Helpers that point at a missing/stale executable (commonly `/snap/gh/<rev>/gh`).
146    pub stale_helpers: Vec<String>,
147    /// Resolved `gh` on PATH, if any.
148    pub gh_path: Option<String>,
149    /// Whether a repair was attempted during this diag run.
150    pub fix_attempted: bool,
151    /// Whether stale helpers were successfully cleared/replaced.
152    pub fixed: bool,
153    /// Human-readable outcome for the report.
154    pub message: Option<String>,
155}
156
157#[derive(Debug, Serialize, Deserialize, Clone)]
158pub struct GitHygiene {
159    pub is_repo: bool,
160    pub branch: Option<String>,
161    pub dirty: Option<bool>,
162    pub ahead: Option<u32>,
163    pub behind: Option<u32>,
164    pub remote: Option<String>,
165    pub notes: Vec<String>,
166    /// Credential helper health (Linux/WSL Snap `gh` drift, etc.).
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub credential_helper: Option<GitCredentialHelperDiag>,
169}
170
171#[derive(Debug, Serialize, Deserialize, Clone)]
172pub struct CliAuthDiagnostic {
173    pub logged_in: bool,
174    pub user_email: Option<String>,
175    pub user_name: Option<String>,
176    pub token_prefix: Option<String>,
177    pub message: String,
178}
179
180#[derive(Debug, Serialize, Deserialize, Clone)]
181pub struct DockerDiagnostic {
182    pub cli_present: bool,
183    pub daemon_ok: bool,
184    pub version: Option<String>,
185    pub compose_present: bool,
186    pub note: Option<String>,
187}
188
189#[derive(Debug, Serialize, Deserialize, Clone)]
190pub struct NetworkInterfaceInfo {
191    pub name: String,
192    pub rx_bytes: u64,
193    pub tx_bytes: u64,
194}
195
196#[derive(Debug, Serialize, Deserialize, Clone)]
197pub struct DiagScorecard {
198    pub healthy: usize,
199    pub warnings: usize,
200    pub failures: usize,
201    pub grade: String,
202}
203
204#[derive(Debug, Serialize, Deserialize)]
205pub struct DiagnosticReport {
206    pub system_metrics: SystemMetrics,
207    pub os: Option<OsInfo>,
208    pub cpu: Option<CpuInfo>,
209    pub gpu_candidates: Vec<String>,
210    pub disks: Vec<DiskInfo>,
211    pub shell: Option<ShellInfo>,
212    pub proxy_detection: Option<ProxyDetection>,
213    pub clipboard_tools: HashMap<String, bool>,
214    pub exposure: Option<ExposureInfo>,
215    pub pm2_process_count: Option<usize>,
216    pub tool_versions: HashMap<String, ToolVersion>,
217    pub service_statuses: HashMap<String, String>,
218    pub feature_flags: HashMap<String, bool>,
219    pub xbp_cli_version: String,
220    pub hostname: Option<String>,
221    pub cwd: String,
222    pub local_ips: Vec<String>,
223    pub network_interfaces: Vec<NetworkInterfaceInfo>,
224    pub project_config_present: bool,
225    pub configured_services: Vec<String>,
226    pub provider_manifests: Vec<String>,
227    pub env_files: Vec<String>,
228    pub nginx_status: Option<NginxStatus>,
229    pub port_checks: Vec<PortCheck>,
230    pub internet_speed: Option<InternetSpeed>,
231    pub connectivity: bool,
232    pub dns_checks: Vec<DnsCheck>,
233    pub http_probes: Vec<HttpProbe>,
234    pub git: Option<GitHygiene>,
235    pub cli_auth: Option<CliAuthDiagnostic>,
236    pub docker: Option<DockerDiagnostic>,
237    pub installed_programs: HashMap<String, bool>,
238    pub path_permission_checks: Vec<PathPermissionCheck>,
239    pub codetime: Option<CodeTimeDiagnostic>,
240    pub worktree_watch: Option<WorktreeWatchDiagnostic>,
241    pub recommendations: Vec<String>,
242    pub scorecard: DiagScorecard,
243}
244
245#[derive(Debug, Serialize, Deserialize)]
246pub struct CodeTimeDiagnostic {
247    pub saved_config_path: String,
248    pub refreshed: bool,
249    pub inventory: SystemInventory,
250}
251
252#[derive(Debug, Serialize, Deserialize)]
253pub struct NginxStatus {
254    pub is_running: bool,
255    pub is_enabled: bool,
256    pub config_valid: bool,
257    pub error: Option<String>,
258}
259
260const PM2_DIAG_TIMEOUT: Duration = Duration::from_secs(12);
261const PORT_OWNERSHIP_TIMEOUT: Duration = Duration::from_secs(20);
262const PER_PORT_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
263const TOOL_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
264const PROGRAM_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
265const COMMAND_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
266const INTERNET_SPEED_TIMEOUT: Duration = Duration::from_secs(20);
267const NGINX_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
268const SYSTEM_INVENTORY_TIMEOUT: Duration = Duration::from_secs(25);
269const DOWNLOAD_PROBE_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
270const DOWNLOAD_PROBE_MAX_DURATION: Duration = Duration::from_secs(8);
271const DOWNLOAD_PROBE_TARGET_BYTES: u64 = 8_000_000;
272const DOWNLOAD_PROBE_MIN_BYTES: u64 = 1_000_000;
273const DOWNLOAD_PROBE_URLS: &[&str] = &[
274    "https://speed.cloudflare.com/__down?bytes=20000000",
275    "https://speed.hetzner.de/10MB.bin",
276    "https://proof.ovh.net/files/10Mb.dat",
277];
278
279#[derive(Debug, Clone, Copy, Default)]
280pub struct DiagnosticRunOptions {
281    pub skip_speed_test: bool,
282    pub codetime: bool,
283    pub cursor: bool,
284    /// Skip DNS + HTTP probes (quick mode).
285    pub quick: bool,
286    /// Enable HTTP probes + DNS + git + auth + docker (full mode).
287    pub full: bool,
288    /// Explicitly enable HTTP probes on configured services.
289    pub http_probe: bool,
290}
291
292pub async fn get_system_metrics() -> Result<SystemMetrics> {
293    let mut sys = System::new_all();
294    sys.refresh_all();
295
296    let cpu_usage =
297        sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
298    let memory_total = sys.total_memory();
299    let memory_used = sys.used_memory();
300    let memory_percent = (memory_used as f32 / memory_total as f32) * 100.0;
301
302    let mut disk_total = 0;
303    let mut disk_used = 0;
304    for disk in Disks::new_with_refreshed_list().iter() {
305        disk_total += disk.total_space();
306        disk_used += disk.total_space() - disk.available_space();
307    }
308    let disk_percent = if disk_total > 0 {
309        (disk_used as f32 / disk_total as f32) * 100.0
310    } else {
311        0.0
312    };
313
314    let networks = Networks::new_with_refreshed_list();
315    let mut network_rx = 0;
316    let mut network_tx = 0;
317    for (_interface_name, network) in &networks {
318        network_rx += network.total_received();
319        network_tx += network.total_transmitted();
320    }
321
322    let uptime = System::uptime();
323    let process_count = sys.processes().len();
324
325    Ok(SystemMetrics {
326        cpu_usage,
327        memory_total,
328        memory_used,
329        memory_percent,
330        disk_total,
331        disk_used,
332        disk_percent,
333        network_rx,
334        network_tx,
335        uptime,
336        process_count,
337    })
338}
339
340pub async fn check_nginx_status() -> Result<NginxStatus> {
341    let is_running: bool = check_systemctl_status("nginx").await?;
342    let is_enabled: bool = check_systemctl_enabled("nginx").await?;
343
344    let config_valid: bool = if is_running {
345        match Command::new("nginx").arg("-t").output().await {
346            Ok(output) => output.status.success(),
347            Err(_) => false,
348        }
349    } else {
350        false
351    };
352
353    Ok(NginxStatus {
354        is_running,
355        is_enabled,
356        config_valid,
357        error: None,
358    })
359}
360
361async fn check_systemctl_status(service: &str) -> Result<bool> {
362    if !cfg!(target_os = "linux") || !command_exists("systemctl") {
363        return Ok(false);
364    }
365
366    let output = Command::new("systemctl")
367        .arg("is-active")
368        .arg(service)
369        .output()
370        .await?;
371
372    Ok(output.status.success())
373}
374
375async fn check_systemctl_enabled(service: &str) -> Result<bool> {
376    if !cfg!(target_os = "linux") || !command_exists("systemctl") {
377        return Ok(false);
378    }
379
380    let output = Command::new("systemctl")
381        .arg("is-enabled")
382        .arg(service)
383        .output()
384        .await?;
385
386    Ok(output.status.success())
387}
388
389pub async fn check_port_availability(
390    port: u16,
391    ownership: Option<&ListeningPortOwnership>,
392) -> Result<PortCheck> {
393    use std::net::TcpListener;
394
395    let is_open: bool = TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok();
396
397    let is_blocked: bool = if cfg!(target_os = "linux") {
398        check_firewall_blocked(port).await.unwrap_or(false)
399    } else {
400        false
401    };
402
403    Ok(PortCheck {
404        port,
405        is_open,
406        is_blocked,
407        pids: ownership
408            .map(|value| value.pids.clone())
409            .unwrap_or_default(),
410        xbp_projects: ownership
411            .map(|value| value.xbp_projects.clone())
412            .unwrap_or_default(),
413    })
414}
415
416async fn check_firewall_blocked(port: u16) -> Result<bool> {
417    let output: std::process::Output = Command::new("iptables")
418        .arg("-L")
419        .arg("-n")
420        .output()
421        .await?;
422
423    if !output.status.success() {
424        return Ok(false);
425    }
426
427    let stdout = String::from_utf8_lossy(&output.stdout);
428    let port_str = port.to_string();
429    Ok(stdout.contains(&format!("dpt:{}", port_str)) && stdout.contains("DROP"))
430}
431
432pub async fn check_internet_connectivity() -> Result<bool> {
433    let result: std::process::Output = if cfg!(target_os = "windows") {
434        Command::new("ping")
435            .arg("-n")
436            .arg("1")
437            .arg("-w")
438            .arg("2000")
439            .arg("8.8.8.8")
440            .output()
441            .await?
442    } else {
443        Command::new("ping")
444            .arg("-c")
445            .arg("1")
446            .arg("-W")
447            .arg("2")
448            .arg("8.8.8.8")
449            .output()
450            .await?
451    };
452
453    Ok(result.status.success())
454}
455
456pub async fn measure_internet_speed() -> Result<InternetSpeed> {
457    if let Ok(speed) = measure_speed_with_speedtest_cli().await {
458        if speed.download_mbps >= 1.0 || speed.upload_mbps >= 0.5 {
459            return Ok(speed);
460        }
461    }
462
463    let ping: f64 = measure_ping().await?;
464    let download_mbps = measure_download_speed().await.unwrap_or(0.0);
465    let upload_mbps = 0.0;
466
467    Ok(InternetSpeed {
468        download_mbps,
469        upload_mbps,
470        ping_ms: ping,
471    })
472}
473
474fn parse_speed_value(token: &str) -> Option<f64> {
475    token.trim().replace(',', ".").parse::<f64>().ok()
476}
477
478async fn measure_speed_with_speedtest_cli() -> Result<InternetSpeed> {
479    let output: std::process::Output = Command::new("speedtest-cli")
480        .arg("--simple")
481        .output()
482        .await?;
483
484    if !output.status.success() {
485        return Err(anyhow::anyhow!("speedtest-cli failed"));
486    }
487
488    let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
489    let mut ping_ms: f64 = 0.0;
490    let mut download_mbps: f64 = 0.0;
491    let mut upload_mbps: f64 = 0.0;
492
493    for line in stdout.lines() {
494        if line.starts_with("Ping:") {
495            if let Some(val) = line.split_whitespace().nth(1) {
496                ping_ms = parse_speed_value(val).unwrap_or(0.0);
497            }
498        } else if line.starts_with("Download:") {
499            if let Some(val) = line.split_whitespace().nth(1) {
500                download_mbps = parse_speed_value(val).unwrap_or(0.0);
501            }
502        } else if line.starts_with("Upload:") {
503            if let Some(val) = line.split_whitespace().nth(1) {
504                upload_mbps = parse_speed_value(val).unwrap_or(0.0);
505            }
506        }
507    }
508
509    Ok(InternetSpeed {
510        download_mbps,
511        upload_mbps,
512        ping_ms,
513    })
514}
515
516async fn measure_ping() -> Result<f64> {
517    let output: std::process::Output = if cfg!(target_os = "windows") {
518        Command::new("ping")
519            .arg("-n")
520            .arg("4")
521            .arg("8.8.8.8")
522            .output()
523            .await?
524    } else {
525        Command::new("ping")
526            .arg("-c")
527            .arg("4")
528            .arg("8.8.8.8")
529            .output()
530            .await?
531    };
532
533    if !output.status.success() {
534        return Ok(0.0);
535    }
536
537    let stdout = String::from_utf8_lossy(&output.stdout);
538
539    if cfg!(target_os = "windows") {
540        for line in stdout.lines() {
541            if line.contains("Average") {
542                if let Some(avg_part) = line.split('=').next_back() {
543                    let avg_str = avg_part.trim().trim_end_matches("ms");
544                    if let Ok(avg) = avg_str.parse::<f64>() {
545                        return Ok(avg);
546                    }
547                }
548            }
549        }
550    } else {
551        for line in stdout.lines() {
552            if line.contains("avg") || line.contains("rtt") {
553                if let Some(avg_str) = line.split('/').nth(4) {
554                    if let Ok(avg) = avg_str.trim().parse::<f64>() {
555                        return Ok(avg);
556                    }
557                }
558            }
559        }
560    }
561
562    Ok(0.0)
563}
564
565async fn measure_download_speed() -> Result<f64> {
566    let client: reqwest::Client = reqwest::Client::builder()
567        .timeout(DOWNLOAD_PROBE_REQUEST_TIMEOUT)
568        .build()?;
569    let mut last_error: Option<String> = None;
570
571    for url in DOWNLOAD_PROBE_URLS {
572        match measure_download_speed_probe(&client, url).await {
573            Ok(mbps) => return Ok(mbps),
574            Err(err) => last_error = Some(err.to_string()),
575        }
576    }
577
578    Err(anyhow::anyhow!(
579        "all download probes failed{}",
580        last_error
581            .map(|value| format!(" (last error: {})", value))
582            .unwrap_or_default()
583    ))
584}
585
586async fn measure_download_speed_probe(client: &reqwest::Client, url: &str) -> Result<f64> {
587    let start = Instant::now();
588    let mut response: reqwest::Response = client
589        .get(url)
590        .header("Cache-Control", "no-cache")
591        .header("Pragma", "no-cache")
592        .send()
593        .await?;
594
595    if !response.status().is_success() {
596        return Err(anyhow::anyhow!(
597            "download probe {} returned status {}",
598            url,
599            response.status()
600        ));
601    }
602
603    let mut total_bytes: u64 = 0;
604
605    loop {
606        if total_bytes >= DOWNLOAD_PROBE_TARGET_BYTES
607            || start.elapsed() >= DOWNLOAD_PROBE_MAX_DURATION
608        {
609            break;
610        }
611
612        let chunk = response.chunk().await?;
613        let Some(bytes) = chunk else {
614            break;
615        };
616
617        total_bytes = total_bytes.saturating_add(bytes.len() as u64);
618    }
619
620    if total_bytes < DOWNLOAD_PROBE_MIN_BYTES {
621        return Err(anyhow::anyhow!(
622            "insufficient sample from {}: {} bytes",
623            url,
624            total_bytes
625        ));
626    }
627
628    let elapsed_seconds = start.elapsed().as_secs_f64().max(0.001);
629    let megabits = (total_bytes as f64 * 8.0) / 1_000_000.0;
630    Ok(megabits / elapsed_seconds)
631}
632
633pub async fn check_installed_programs() -> HashMap<String, bool> {
634    let programs = vec![
635        "jq",
636        "curl",
637        "wget",
638        "nginx",
639        "git",
640        "docker",
641        "docker-compose",
642        "podman",
643        "kubectl",
644        "microk8s",
645        "helm",
646        "node",
647        "npm",
648        "pnpm",
649        "yarn",
650        "bun",
651        "python",
652        "python3",
653        "pip",
654        "pip3",
655        "uv",
656        "cargo",
657        "rustc",
658        "rustup",
659        "pm2",
660        "systemctl",
661        "launchctl",
662        "ss",
663        "lsof",
664        "nc",
665        "nmap",
666        "openssl",
667        "dig",
668        "nslookup",
669        "cloudflared",
670        "wrangler",
671        "gh",
672        "rg",
673        "fd",
674        "make",
675        "cmake",
676        "speedtest-cli",
677    ];
678
679    let mut results = HashMap::new();
680
681    for program in programs {
682        let is_installed = check_program_installed(program).await;
683        results.insert(program.to_string(), is_installed);
684    }
685
686    results
687}
688
689fn can_create_file_in_dir(dir: &std::path::Path) -> bool {
690    use std::time::{SystemTime, UNIX_EPOCH};
691
692    if !dir.is_dir() {
693        return false;
694    }
695
696    let nonce = SystemTime::now()
697        .duration_since(UNIX_EPOCH)
698        .map(|d| d.as_nanos())
699        .unwrap_or_default();
700    let probe = dir.join(format!(
701        ".xbp_diag_perm_probe_{}_{}",
702        std::process::id(),
703        nonce
704    ));
705
706    match OpenOptions::new().write(true).create_new(true).open(&probe) {
707        Ok(_) => {
708            let _ = fs::remove_file(&probe);
709            true
710        }
711        Err(_) => false,
712    }
713}
714
715fn check_path_permission_entry(
716    path: PathBuf,
717    purpose: &str,
718    require_read: bool,
719    require_write: bool,
720    require_create: bool,
721    optional: bool,
722) -> PathPermissionCheck {
723    let exists = path.exists();
724    let mut readable = false;
725    let mut writable = false;
726    let mut creatable = false;
727    let mut message = None;
728
729    if exists {
730        if path.is_dir() {
731            readable = fs::read_dir(&path).is_ok();
732            writable = can_create_file_in_dir(&path);
733            creatable = writable;
734        } else if path.is_file() {
735            readable = fs::File::open(&path).is_ok();
736            writable = OpenOptions::new().write(true).open(&path).is_ok();
737            if let Some(parent) = path.parent() {
738                creatable = can_create_file_in_dir(parent);
739            }
740        } else {
741            message = Some("Path exists but is not a regular file/directory".to_string());
742        }
743    } else if let Some(parent) = path.parent() {
744        // Missing path: we can still estimate create capability via parent directory.
745        creatable = can_create_file_in_dir(parent);
746        readable = parent.exists() && fs::read_dir(parent).is_ok();
747        writable = parent.exists() && can_create_file_in_dir(parent);
748        if optional {
749            message = Some("Path missing (optional)".to_string());
750        } else if require_create && !creatable {
751            message =
752                Some("Path missing and cannot be created with current permissions".to_string());
753        } else {
754            message = Some("Path missing".to_string());
755        }
756    } else if optional {
757        message = Some("Path missing (optional)".to_string());
758    } else {
759        message = Some("Path missing".to_string());
760    }
761
762    let mut missing = Vec::new();
763    if require_read && !readable {
764        missing.push("read");
765    }
766    if require_write && !writable {
767        missing.push("write");
768    }
769    if require_create && !creatable {
770        missing.push("create");
771    }
772
773    let mut ok = missing.is_empty();
774    if optional && !exists {
775        ok = true;
776    }
777    if !ok {
778        let detail = format!("Missing required access: {}", missing.join(", "));
779        message = match message {
780            Some(existing) => Some(format!("{}; {}", existing, detail)),
781            None => Some(detail),
782        };
783    }
784
785    let required = match (require_read, require_write, require_create) {
786        (true, true, true) => "read/write/create",
787        (true, true, false) => "read/write",
788        (true, false, true) => "read/create",
789        (false, true, true) => "write/create",
790        (true, false, false) => "read",
791        (false, true, false) => "write",
792        (false, false, true) => "create",
793        (false, false, false) => "none",
794    }
795    .to_string();
796
797    PathPermissionCheck {
798        path: path.display().to_string(),
799        purpose: purpose.to_string(),
800        required,
801        exists,
802        readable,
803        writable,
804        creatable,
805        ok,
806        message,
807    }
808}
809
810async fn get_path_permission_checks() -> Vec<PathPermissionCheck> {
811    let mut checks = Vec::new();
812
813    if cfg!(target_os = "linux") {
814        checks.push(check_path_permission_entry(
815            PathBuf::from("/etc/systemd/system"),
816            "Install/update systemd unit files",
817            true,
818            true,
819            true,
820            false,
821        ));
822        checks.push(check_path_permission_entry(
823            PathBuf::from("/etc/default/xbp"),
824            "Optional runtime environment file for xbp-api.service",
825            true,
826            true,
827            true,
828            true,
829        ));
830        checks.push(check_path_permission_entry(
831            PathBuf::from("/etc/nginx"),
832            "Read/write Nginx configuration root",
833            true,
834            true,
835            false,
836            false,
837        ));
838        checks.push(check_path_permission_entry(
839            PathBuf::from("/etc/nginx/sites-available"),
840            "Manage Nginx site configurations",
841            true,
842            true,
843            true,
844            false,
845        ));
846        checks.push(check_path_permission_entry(
847            PathBuf::from("/etc/nginx/sites-enabled"),
848            "Enable/disable Nginx sites",
849            true,
850            true,
851            true,
852            false,
853        ));
854        checks.push(check_path_permission_entry(
855            PathBuf::from("/var/log"),
856            "Write XBP and service log files",
857            true,
858            true,
859            true,
860            false,
861        ));
862        checks.push(check_path_permission_entry(
863            PathBuf::from("/var/lib/xbp"),
864            "State directory used by generated systemd services",
865            true,
866            true,
867            true,
868            true,
869        ));
870        checks.push(check_path_permission_entry(
871            PathBuf::from("/var/log/nginx"),
872            "Read Nginx access/error logs for diagnostics and metrics",
873            true,
874            true,
875            true,
876            true,
877        ));
878        checks.push(check_path_permission_entry(
879            PathBuf::from("/run"),
880            "Runtime sockets/state files used by services",
881            true,
882            true,
883            true,
884            false,
885        ));
886        checks.push(check_path_permission_entry(
887            PathBuf::from("/usr/local/bin/xbp"),
888            "Installed XBP CLI binary path",
889            true,
890            true,
891            true,
892            true,
893        ));
894    } else if let Ok(current) = env::current_dir() {
895        checks.push(check_path_permission_entry(
896            current,
897            "Current project directory",
898            true,
899            true,
900            true,
901            false,
902        ));
903    }
904
905    if let Ok(current) = env::current_dir() {
906        checks.push(check_path_permission_entry(
907            current.join(".xbp"),
908            "Local XBP project metadata directory",
909            true,
910            true,
911            true,
912            true,
913        ));
914    }
915
916    // Established global XBP home (shared Windows/WSL mutations + config).
917    if let Ok(paths) = ensure_global_xbp_paths() {
918        checks.push(check_path_permission_entry(
919            paths.root_dir.clone(),
920            "Global XBP home (config + worktree-watch mutations)",
921            true,
922            true,
923            true,
924            true,
925        ));
926        checks.push(check_path_permission_entry(
927            paths.root_dir.join("mutations"),
928            "Worktree-watch mutation spool root under global XBP home",
929            true,
930            true,
931            true,
932            true,
933        ));
934    }
935
936    checks
937}
938
939async fn run_command_with_timeout(
940    program: &str,
941    args: &[&str],
942    timeout: Duration,
943) -> Option<std::process::Output> {
944    let mut cmd = Command::new(program);
945    cmd.args(args);
946    cmd.kill_on_drop(true);
947    match tokio::time::timeout(timeout, cmd.output()).await {
948        Ok(Ok(output)) => Some(output),
949        _ => None,
950    }
951}
952
953async fn check_program_installed(program: &str) -> bool {
954    let output = if cfg!(target_os = "windows") {
955        run_command_with_timeout("where", &[program], PROGRAM_CHECK_TIMEOUT).await
956    } else {
957        run_command_with_timeout("which", &[program], PROGRAM_CHECK_TIMEOUT).await
958    };
959
960    output.map(|value| value.status.success()).unwrap_or(false)
961}
962
963fn render_percent_bar(percent: f32, width: usize) -> String {
964    let pct = percent.clamp(0.0, 100.0);
965    let filled = ((pct / 100.0) * width as f32).round() as usize;
966    let filled = filled.min(width);
967    let empty = width.saturating_sub(filled);
968    format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty))
969}
970
971async fn get_os_info() -> Option<OsInfo> {
972    Some(OsInfo {
973        name: System::name(),
974        version: System::os_version(),
975        kernel_version: System::kernel_version(),
976        arch: Some(std::env::consts::ARCH.to_string()),
977    })
978}
979
980async fn get_cpu_info() -> Option<CpuInfo> {
981    let mut sys = System::new_all();
982    sys.refresh_cpu_all();
983    let cores = sys.cpus().len();
984    let usage = if cores > 0 {
985        sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / cores as f32
986    } else {
987        0.0
988    };
989    let brand = sys.cpus().first().map(|c| c.brand().to_string());
990    Some(CpuInfo {
991        brand,
992        cores,
993        usage,
994    })
995}
996
997async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
998    let output = run_command_with_timeout(program, args, COMMAND_CAPTURE_TIMEOUT).await?;
999    let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
1000    let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
1001    let combined: String = if !stdout.is_empty() { stdout } else { stderr };
1002    if combined.is_empty() {
1003        None
1004    } else {
1005        Some(combined)
1006    }
1007}
1008
1009async fn get_gpu_candidates() -> Vec<String> {
1010    let mut out: Vec<String> = Vec::new();
1011
1012    if cfg!(target_os = "windows") {
1013        if let Some(s) = run_command_capture(
1014            "powershell",
1015            &[
1016                "-Command",
1017                "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
1018            ],
1019        )
1020        .await
1021        {
1022            for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1023                out.push(line.to_string());
1024            }
1025        }
1026        return out;
1027    }
1028
1029    if let Some(s) = run_command_capture("nvidia-smi", &["-L"]).await {
1030        for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1031            out.push(line.to_string());
1032        }
1033    }
1034
1035    if out.is_empty() {
1036        if let Some(s) =
1037            run_command_capture("sh", &["-c", "lspci | grep -Ei 'vga|3d|display'"]).await
1038        {
1039            for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1040                out.push(line.to_string());
1041            }
1042        }
1043    }
1044
1045    if out.is_empty() {
1046        if let Some(s) =
1047            run_command_capture("sh", &["-c", "lshw -C display 2>/dev/null | head"]).await
1048        {
1049            for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1050                out.push(line.to_string());
1051            }
1052        }
1053    }
1054
1055    out
1056}
1057
1058async fn get_disk_infos() -> Vec<DiskInfo> {
1059    let disks = Disks::new_with_refreshed_list();
1060    let cwd = env::current_dir().ok();
1061
1062    let mut candidates: Vec<(usize, usize)> = Vec::new();
1063    if let Some(cwd) = &cwd {
1064        for (i, d) in disks.iter().enumerate() {
1065            let mp = d.mount_point();
1066            if cwd.starts_with(mp) {
1067                candidates.push((mp.as_os_str().len(), i));
1068            }
1069        }
1070    }
1071    candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
1072    let current_idx = candidates.first().map(|(_, idx)| *idx);
1073
1074    disks
1075        .iter()
1076        .enumerate()
1077        .map(|(i, d)| {
1078            let total = d.total_space();
1079            let used = total.saturating_sub(d.available_space());
1080            let percent = if total > 0 {
1081                (used as f32 / total as f32) * 100.0
1082            } else {
1083                0.0
1084            };
1085            let mount = d.mount_point().to_string_lossy().to_string();
1086            let fs = if d.file_system().is_empty() {
1087                None
1088            } else {
1089                Some(d.file_system().to_string_lossy().to_string())
1090            };
1091            DiskInfo {
1092                mount,
1093                fs,
1094                total,
1095                used,
1096                percent,
1097                bar: render_percent_bar(percent, 20),
1098                is_current: current_idx == Some(i),
1099            }
1100        })
1101        .collect()
1102}
1103
1104fn get_shell_info() -> Option<ShellInfo> {
1105    let shell = env::var("SHELL")
1106        .ok()
1107        .or_else(|| env::var("ComSpec").ok())
1108        .or_else(|| env::var("0").ok());
1109    let term = env::var("TERM").ok();
1110    if shell.is_none() && term.is_none() {
1111        None
1112    } else {
1113        Some(ShellInfo { shell, term })
1114    }
1115}
1116
1117async fn systemctl_active(service: &str) -> Option<bool> {
1118    if !cfg!(target_os = "linux") || !command_exists("systemctl") {
1119        return None;
1120    }
1121    let output = Command::new("systemctl")
1122        .arg("is-active")
1123        .arg(service)
1124        .output()
1125        .await
1126        .ok()?;
1127    Some(output.status.success())
1128}
1129
1130async fn get_service_statuses() -> HashMap<String, String> {
1131    let mut out = HashMap::new();
1132    let services = [
1133        "nginx",
1134        "traefik",
1135        "caddy",
1136        "apache2",
1137        "httpd",
1138        "postgresql",
1139        "postgres",
1140        "mysql",
1141        "mariadb",
1142        "redis",
1143        "redis-server",
1144        "postgrest",
1145        "prometheus",
1146        "grafana-server",
1147        "grafana",
1148        "docker",
1149        "containerd",
1150        "ssh",
1151        "sshd",
1152        "fail2ban",
1153        "kafka",
1154        "xbp",
1155        "xbp-api",
1156        "xbp-daemon",
1157    ];
1158
1159    for s in services {
1160        let v = systemctl_active(s).await;
1161        let status = match v {
1162            Some(true) => "active",
1163            Some(false) => "inactive",
1164            None => "unknown",
1165        };
1166        out.insert(s.to_string(), status.to_string());
1167    }
1168
1169    out
1170}
1171
1172async fn get_proxy_detection() -> Option<ProxyDetection> {
1173    let nginx_sites_available = if cfg!(target_os = "linux") {
1174        let p = PathBuf::from("/etc/nginx/sites-available");
1175        if p.exists() {
1176            fs::read_dir(&p)
1177                .ok()
1178                .map(|rd| rd.filter_map(|e| e.ok()).count())
1179        } else {
1180            None
1181        }
1182    } else {
1183        None
1184    };
1185
1186    let nginx_active = systemctl_active("nginx").await;
1187    let traefik_active = systemctl_active("traefik").await;
1188    let apache_active = match systemctl_active("apache2").await {
1189        Some(v) => Some(v),
1190        None => systemctl_active("httpd").await,
1191    };
1192
1193    if nginx_sites_available.is_none()
1194        && nginx_active.is_none()
1195        && traefik_active.is_none()
1196        && apache_active.is_none()
1197    {
1198        None
1199    } else {
1200        Some(ProxyDetection {
1201            nginx_sites_available,
1202            nginx_active,
1203            traefik_active,
1204            apache_active,
1205        })
1206    }
1207}
1208
1209async fn get_clipboard_tools() -> HashMap<String, bool> {
1210    let mut tools = vec!["xclip", "xsel", "wl-copy", "pbcopy"];
1211    if cfg!(target_os = "windows") {
1212        tools.push("clip");
1213    }
1214    let mut out = HashMap::new();
1215    for t in tools {
1216        out.insert(t.to_string(), check_program_installed(t).await);
1217    }
1218    out
1219}
1220
1221fn get_listeners_on_all_interfaces() -> Vec<String> {
1222    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
1223    let proto_flags = ProtocolFlags::TCP;
1224    let sockets = get_sockets_info(af_flags, proto_flags).unwrap_or_default();
1225
1226    let mut out = Vec::new();
1227    for socket in sockets {
1228        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
1229            let state = format!("{:?}", tcp.state);
1230            if state != "Listen" && state != "LISTEN" {
1231                continue;
1232            }
1233            let addr = tcp.local_addr.to_string();
1234            if addr == "0.0.0.0" || addr == "::" {
1235                out.push(format!("{}:{}", addr, tcp.local_port));
1236            }
1237        }
1238    }
1239    out.sort();
1240    out.dedup();
1241    out
1242}
1243
1244async fn get_firewall_hint() -> Option<String> {
1245    if !cfg!(target_os = "linux") {
1246        return None;
1247    }
1248
1249    if let Some(s) = run_command_capture("sh", &["-c", "ufw status 2>/dev/null | head -n 1"]).await
1250    {
1251        if !s.trim().is_empty() {
1252            return Some(s.trim().to_string());
1253        }
1254    }
1255
1256    if let Some(s) =
1257        run_command_capture("sh", &["-c", "nft list ruleset 2>/dev/null | head -n 1"]).await
1258    {
1259        if !s.trim().is_empty() {
1260            return Some("nftables detected".to_string());
1261        }
1262    }
1263
1264    if let Some(s) = run_command_capture("sh", &["-c", "iptables -S 2>/dev/null | head -n 1"]).await
1265    {
1266        if !s.trim().is_empty() {
1267            return Some("iptables detected".to_string());
1268        }
1269    }
1270
1271    None
1272}
1273
1274async fn get_public_ipv4(connectivity: bool) -> Option<String> {
1275    if !connectivity {
1276        return None;
1277    }
1278    let client = reqwest::Client::builder()
1279        .timeout(Duration::from_secs(3))
1280        .build()
1281        .ok()?;
1282    let text = client
1283        .get("https://api.ipify.org")
1284        .send()
1285        .await
1286        .ok()?
1287        .text()
1288        .await
1289        .ok()?;
1290    let ip = text.trim().to_string();
1291    if ip.is_empty() {
1292        None
1293    } else {
1294        Some(ip)
1295    }
1296}
1297
1298async fn get_exposure_info(connectivity: bool) -> Option<ExposureInfo> {
1299    let public_ipv4 = get_public_ipv4(connectivity).await;
1300    let listening_on_all_interfaces = get_listeners_on_all_interfaces();
1301    let firewall_hint = get_firewall_hint().await;
1302    let summary = Some(if listening_on_all_interfaces.is_empty() {
1303        "No listeners on 0.0.0.0/:: detected".to_string()
1304    } else {
1305        "Listeners on 0.0.0.0/:: detected (may be publicly reachable)".to_string()
1306    });
1307    Some(ExposureInfo {
1308        public_ipv4,
1309        listening_on_all_interfaces,
1310        firewall_hint,
1311        summary,
1312    })
1313}
1314
1315async fn get_pm2_process_count() -> Option<usize> {
1316    let mut cmd = if cfg!(target_os = "windows") {
1317        let mut cmd = Command::new("powershell");
1318        cmd.arg("-NoProfile").arg("-Command").arg("pm2 jlist");
1319        cmd
1320    } else {
1321        let mut cmd = Command::new("pm2");
1322        cmd.arg("jlist");
1323        cmd
1324    };
1325    cmd.kill_on_drop(true);
1326
1327    let output = match tokio::time::timeout(PM2_DIAG_TIMEOUT, cmd.output()).await {
1328        Ok(Ok(output)) => output,
1329        Ok(Err(_)) | Err(_) => return None,
1330    };
1331
1332    if !output.status.success() {
1333        return None;
1334    }
1335    let stdout = String::from_utf8_lossy(&output.stdout);
1336    let value: serde_json::Value = serde_json::from_str(&stdout).ok()?;
1337    value.as_array().map(|arr| arr.len())
1338}
1339
1340async fn get_version(cmd: &str, args: &[&str]) -> ToolVersion {
1341    match run_command_with_timeout(cmd, args, TOOL_VERSION_TIMEOUT).await {
1342        Some(o) => {
1343            let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
1344            let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1345            let line = stdout
1346                .lines()
1347                .next()
1348                .filter(|l| !l.trim().is_empty())
1349                .map(|l| l.trim().to_string())
1350                .or_else(|| {
1351                    stderr
1352                        .lines()
1353                        .next()
1354                        .filter(|l| !l.trim().is_empty())
1355                        .map(|l| l.trim().to_string())
1356                });
1357            ToolVersion {
1358                present: o.status.success() || line.is_some(),
1359                version: line,
1360            }
1361        }
1362        None => ToolVersion {
1363            present: false,
1364            version: None,
1365        },
1366    }
1367}
1368
1369async fn get_tool_versions() -> HashMap<String, ToolVersion> {
1370    let mut out = HashMap::new();
1371
1372    out.insert(
1373        "python3".to_string(),
1374        get_version("python3", &["--version"]).await,
1375    );
1376    out.insert(
1377        "python".to_string(),
1378        get_version("python", &["--version"]).await,
1379    );
1380    out.insert(
1381        "node".to_string(),
1382        get_version("node", &["--version"]).await,
1383    );
1384    out.insert("npm".to_string(), get_version("npm", &["--version"]).await);
1385    out.insert(
1386        "pnpm".to_string(),
1387        get_version("pnpm", &["--version"]).await,
1388    );
1389    out.insert("pm2".to_string(), get_version("pm2", &["--version"]).await);
1390    out.insert(
1391        "docker".to_string(),
1392        get_version("docker", &["--version"]).await,
1393    );
1394    out.insert(
1395        "docker-compose".to_string(),
1396        get_version("docker-compose", &["--version"]).await,
1397    );
1398    out.insert(
1399        "kubectl".to_string(),
1400        get_version("kubectl", &["version", "--client"]).await,
1401    );
1402    out.insert(
1403        "microk8s".to_string(),
1404        get_version("microk8s", &["version"]).await,
1405    );
1406    out.insert(
1407        "rustc".to_string(),
1408        get_version("rustc", &["--version"]).await,
1409    );
1410    out.insert(
1411        "cargo".to_string(),
1412        get_version("cargo", &["--version"]).await,
1413    );
1414    out.insert(
1415        "rustup".to_string(),
1416        get_version("rustup", &["--version"]).await,
1417    );
1418    out.insert("git".to_string(), get_version("git", &["--version"]).await);
1419    out.insert(
1420        "wrangler".to_string(),
1421        get_version("wrangler", &["--version"]).await,
1422    );
1423    out.insert(
1424        "cloudflared".to_string(),
1425        get_version("cloudflared", &["--version"]).await,
1426    );
1427    out.insert("gh".to_string(), get_version("gh", &["--version"]).await);
1428    out.insert(
1429        "openssl".to_string(),
1430        get_version("openssl", &["version"]).await,
1431    );
1432    out.insert(
1433        "bun".to_string(),
1434        get_version("bun", &["--version"]).await,
1435    );
1436
1437    out
1438}
1439
1440async fn collect_local_ips() -> Vec<String> {
1441    let mut ips = Vec::new();
1442    let networks = Networks::new_with_refreshed_list();
1443    for (name, data) in &networks {
1444        let _ = name;
1445        for addr in data.ip_networks() {
1446            let s = addr.addr.to_string();
1447            if s == "127.0.0.1" || s == "::1" || s.starts_with("fe80:") {
1448                continue;
1449            }
1450            ips.push(s);
1451        }
1452    }
1453    ips.sort();
1454    ips.dedup();
1455    ips
1456}
1457
1458fn collect_network_interfaces() -> Vec<NetworkInterfaceInfo> {
1459    let networks = Networks::new_with_refreshed_list();
1460    let mut out = Vec::new();
1461    for (name, data) in &networks {
1462        out.push(NetworkInterfaceInfo {
1463            name: name.clone(),
1464            rx_bytes: data.total_received(),
1465            tx_bytes: data.total_transmitted(),
1466        });
1467    }
1468    out.sort_by(|a, b| a.name.cmp(&b.name));
1469    out
1470}
1471
1472fn discover_env_files(cwd: &std::path::Path) -> Vec<String> {
1473    let candidates = [
1474        ".env",
1475        ".env.local",
1476        ".env.production",
1477        ".dev.vars",
1478        ".xbp/.env",
1479        "apps/web/.env.local",
1480        "apps/web/.dev.vars",
1481        "apps/docs/.env.local",
1482    ];
1483    candidates
1484        .iter()
1485        .filter_map(|rel| {
1486            let p = cwd.join(rel);
1487            if p.exists() {
1488                Some(rel.to_string())
1489            } else {
1490                None
1491            }
1492        })
1493        .collect()
1494}
1495
1496async fn collect_dns_checks(hosts: &[&str]) -> Vec<DnsCheck> {
1497    let mut out = Vec::new();
1498    for host in hosts {
1499        let host = (*host).to_string();
1500        match tokio::net::lookup_host(format!("{host}:80")).await {
1501            Ok(addrs) => {
1502                let addresses: Vec<String> = addrs.map(|a| a.ip().to_string()).collect();
1503                out.push(DnsCheck {
1504                    host,
1505                    ok: !addresses.is_empty(),
1506                    addresses,
1507                    error: None,
1508                });
1509            }
1510            Err(err) => out.push(DnsCheck {
1511                host,
1512                ok: false,
1513                addresses: Vec::new(),
1514                error: Some(err.to_string()),
1515            }),
1516        }
1517    }
1518    out
1519}
1520
1521async fn collect_http_probes(services: &[(String, u16, Option<String>)]) -> Vec<HttpProbe> {
1522    let client = match reqwest::Client::builder()
1523        .timeout(Duration::from_secs(4))
1524        .redirect(reqwest::redirect::Policy::limited(3))
1525        .build()
1526    {
1527        Ok(c) => c,
1528        Err(err) => {
1529            return vec![HttpProbe {
1530                name: "http-client".into(),
1531                url: String::new(),
1532                ok: false,
1533                status: None,
1534                latency_ms: None,
1535                error: Some(err.to_string()),
1536            }];
1537        }
1538    };
1539
1540    let mut out = Vec::new();
1541    for (name, port, url_hint) in services {
1542        let candidates = [
1543            url_hint
1544                .as_ref()
1545                .map(|u| u.trim_end_matches('/').to_string())
1546                .filter(|u| !u.is_empty()),
1547            Some(format!("http://127.0.0.1:{port}/")),
1548            Some(format!("http://127.0.0.1:{port}/health")),
1549            Some(format!("http://127.0.0.1:{port}/api/health")),
1550        ];
1551        let mut probed = false;
1552        for url in candidates.into_iter().flatten() {
1553            let started = Instant::now();
1554            match client.get(&url).send().await {
1555                Ok(resp) => {
1556                    let status = resp.status().as_u16();
1557                    let ok = status < 500;
1558                    out.push(HttpProbe {
1559                        name: name.clone(),
1560                        url,
1561                        ok,
1562                        status: Some(status),
1563                        latency_ms: Some(started.elapsed().as_millis() as u64),
1564                        error: None,
1565                    });
1566                    probed = true;
1567                    break;
1568                }
1569                Err(_) => continue,
1570            }
1571        }
1572        if !probed {
1573            out.push(HttpProbe {
1574                name: name.clone(),
1575                url: format!("http://127.0.0.1:{port}/"),
1576                ok: false,
1577                status: None,
1578                latency_ms: None,
1579                error: Some("no successful HTTP response".into()),
1580            });
1581        }
1582    }
1583    out
1584}
1585
1586async fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> Option<String> {
1587    let output = Command::new("git")
1588        .args(args)
1589        .current_dir(cwd)
1590        .output()
1591        .await
1592        .ok()?;
1593    if !output.status.success() {
1594        return None;
1595    }
1596    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1597}
1598
1599/// First absolute (or path-like) executable token from a git `credential.helper` value.
1600///
1601/// Examples:
1602/// - `!/usr/bin/gh auth git-credential` → `/usr/bin/gh`
1603/// - `/snap/gh/751/gh auth git-credential` → `/snap/gh/751/gh`
1604/// - `store` → `None`
1605pub(crate) fn credential_helper_executable(helper: &str) -> Option<String> {
1606    let trimmed = helper.trim();
1607    if trimmed.is_empty() {
1608        return None;
1609    }
1610    let without_bang = trimmed.strip_prefix('!').unwrap_or(trimmed).trim();
1611    let first = without_bang.split_whitespace().next()?.trim();
1612    if first.is_empty() {
1613        return None;
1614    }
1615    // Path-like tokens only (absolute Unix path, or Windows drive path).
1616    let looks_like_path = first.starts_with('/')
1617        || first.starts_with('\\')
1618        || (first.len() > 2 && first.as_bytes()[1] == b':');
1619    if looks_like_path {
1620        Some(first.to_string())
1621    } else {
1622        None
1623    }
1624}
1625
1626/// True when a credential.helper points at a deleted Snap `gh` (or other missing `gh` binary).
1627///
1628/// Snap upgrades drop `/snap/gh/<rev>/gh` while git keeps the revision-specific helper path.
1629pub(crate) fn is_stale_gh_credential_helper(helper: &str) -> bool {
1630    let lower = helper.to_ascii_lowercase();
1631    let mentions_gh_cred = lower.contains("git-credential")
1632        || lower.contains("/gh ")
1633        || lower.ends_with("/gh")
1634        || lower.contains("\\gh ")
1635        || lower.ends_with("\\gh");
1636
1637    if lower.contains("/snap/gh/") {
1638        if let Some(exe) = credential_helper_executable(helper) {
1639            return !std::path::Path::new(&exe).is_file();
1640        }
1641        // Unparseable snap gh helper is still considered stale.
1642        return true;
1643    }
1644
1645    if !mentions_gh_cred {
1646        return false;
1647    }
1648
1649    if let Some(exe) = credential_helper_executable(helper) {
1650        let file_name = std::path::Path::new(&exe)
1651            .file_name()
1652            .and_then(|s| s.to_str())
1653            .unwrap_or("");
1654        let is_gh = file_name == "gh"
1655            || file_name.eq_ignore_ascii_case("gh.exe")
1656            || file_name.starts_with("gh.");
1657        if is_gh {
1658            return !std::path::Path::new(&exe).is_file();
1659        }
1660    }
1661    false
1662}
1663
1664async fn list_credential_helpers() -> Vec<String> {
1665    // Prefer show-origin so operators can see file sources in logs; values alone are enough for fix.
1666    let output = Command::new("git")
1667        .args(["config", "--get-all", "credential.helper"])
1668        .output()
1669        .await;
1670    match output {
1671        Ok(out) if out.status.success() || !out.stdout.is_empty() => {
1672            // git exits 1 when the key is unset; treat empty as no helpers.
1673            String::from_utf8_lossy(&out.stdout)
1674                .lines()
1675                .map(str::trim)
1676                .filter(|line| !line.is_empty())
1677                .map(ToOwned::to_owned)
1678                .collect()
1679        }
1680        _ => Vec::new(),
1681    }
1682}
1683
1684async fn resolve_gh_path() -> Option<String> {
1685    if cfg!(target_os = "windows") {
1686        let output = run_command_with_timeout("where", &["gh"], PROGRAM_CHECK_TIMEOUT).await?;
1687        if !output.status.success() {
1688            return None;
1689        }
1690        let first = String::from_utf8_lossy(&output.stdout)
1691            .lines()
1692            .next()?
1693            .trim()
1694            .to_string();
1695        if first.is_empty() {
1696            None
1697        } else {
1698            Some(first)
1699        }
1700    } else {
1701        let output = run_command_with_timeout("which", &["gh"], PROGRAM_CHECK_TIMEOUT).await?;
1702        if !output.status.success() {
1703            return None;
1704        }
1705        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
1706        if path.is_empty() || !std::path::Path::new(&path).is_file() {
1707            None
1708        } else {
1709            Some(path)
1710        }
1711    }
1712}
1713
1714/// Clear stale credential.helper entries and re-point git at the current `gh` binary.
1715///
1716/// Mirrors the manual recovery flow:
1717/// `git config --global/--system --unset-all credential.helper` then `gh auth setup-git`
1718/// (fallback: set `!{gh} auth git-credential` globally).
1719async fn repair_gh_credential_helper(gh_path: &str) -> Result<Vec<String>, String> {
1720    // Unset global helpers (ignore failure when unset).
1721    let _ = Command::new("git")
1722        .args(["config", "--global", "--unset-all", "credential.helper"])
1723        .output()
1724        .await;
1725
1726    // System may require root; best-effort.
1727    let _ = Command::new("git")
1728        .args(["config", "--system", "--unset-all", "credential.helper"])
1729        .output()
1730        .await;
1731
1732    // Prefer gh's own rewrite of the helper.
1733    let setup = Command::new(gh_path)
1734        .args(["auth", "setup-git"])
1735        .output()
1736        .await
1737        .map_err(|e| format!("failed to run `{gh_path} auth setup-git`: {e}"))?;
1738
1739    if !setup.status.success() {
1740        // Fallback: explicit helper path (works even when gh is not fully logged in).
1741        let helper_value = format!("!{gh_path} auth git-credential");
1742        let set = Command::new("git")
1743            .args(["config", "--global", "credential.helper", &helper_value])
1744            .output()
1745            .await
1746            .map_err(|e| format!("failed to set credential.helper: {e}"))?;
1747        if !set.status.success() {
1748            let stderr = String::from_utf8_lossy(&set.stderr);
1749            let setup_err = String::from_utf8_lossy(&setup.stderr);
1750            return Err(format!(
1751                "`gh auth setup-git` failed ({}); setting helper also failed: {}",
1752                setup_err.trim(),
1753                stderr.trim()
1754            ));
1755        }
1756    }
1757
1758    let helpers = list_credential_helpers().await;
1759    let still_stale: Vec<_> = helpers
1760        .iter()
1761        .filter(|h| is_stale_gh_credential_helper(h))
1762        .cloned()
1763        .collect();
1764    if !still_stale.is_empty() {
1765        return Err(format!(
1766            "stale credential helper(s) remain after repair: {}",
1767            still_stale.join("; ")
1768        ));
1769    }
1770    Ok(helpers)
1771}
1772
1773async fn collect_and_fix_credential_helpers() -> Option<GitCredentialHelperDiag> {
1774    // Snap/gh path drift is a Linux (incl. WSL/Ubuntu) problem; skip pure Windows native git.
1775    if cfg!(target_os = "windows") {
1776        return None;
1777    }
1778
1779    let helpers = list_credential_helpers().await;
1780    let stale_helpers: Vec<String> = helpers
1781        .iter()
1782        .filter(|h| is_stale_gh_credential_helper(h))
1783        .cloned()
1784        .collect();
1785
1786    if stale_helpers.is_empty() {
1787        // Still surface helpers when any gh-style helper is configured (useful for --json).
1788        let has_gh_helper = helpers.iter().any(|h| {
1789            let lower = h.to_ascii_lowercase();
1790            lower.contains("gh") && lower.contains("git-credential")
1791        });
1792        if !has_gh_helper {
1793            return None;
1794        }
1795        let gh_path = resolve_gh_path().await;
1796        return Some(GitCredentialHelperDiag {
1797            helpers,
1798            stale_helpers: Vec::new(),
1799            gh_path,
1800            fix_attempted: false,
1801            fixed: false,
1802            message: Some("GitHub CLI credential helper looks healthy".into()),
1803        });
1804    }
1805
1806    let gh_path = resolve_gh_path().await;
1807    let mut diag = GitCredentialHelperDiag {
1808        helpers: helpers.clone(),
1809        stale_helpers: stale_helpers.clone(),
1810        gh_path: gh_path.clone(),
1811        fix_attempted: false,
1812        fixed: false,
1813        message: None,
1814    };
1815
1816    let Some(gh) = gh_path else {
1817        diag.message = Some(format!(
1818            "Stale Git credential helper(s) point at a missing GitHub CLI binary: {}. \
1819Install native `gh` (not Snap): `sudo apt update && sudo apt install gh`, then `gh auth login` and `gh auth setup-git`.",
1820            stale_helpers.join("; ")
1821        ));
1822        let _ = log_warn(
1823            "diag",
1824            "Stale git credential.helper and gh not on PATH",
1825            diag.message.as_deref(),
1826        )
1827        .await;
1828        return Some(diag);
1829    };
1830
1831    diag.fix_attempted = true;
1832    let stale_summary = stale_helpers.join("; ");
1833    let _ = log_warn(
1834        "diag",
1835        "Stale git credential.helper (deleted Snap/missing gh path)",
1836        Some(&stale_summary),
1837    )
1838    .await;
1839    match repair_gh_credential_helper(&gh).await {
1840        Ok(new_helpers) => {
1841            diag.fixed = true;
1842            diag.helpers = new_helpers;
1843            diag.stale_helpers = Vec::new();
1844            diag.message = Some(format!(
1845                "Repaired stale Snap/missing `gh` credential helper → using `{gh}` (`gh auth setup-git` / explicit helper)."
1846            ));
1847            let _ = log_info(
1848                "diag",
1849                "Repaired git credential.helper to current gh",
1850                Some(gh.as_str()),
1851            )
1852            .await;
1853        }
1854        Err(err) => {
1855            diag.fixed = false;
1856            diag.helpers = list_credential_helpers().await;
1857            diag.stale_helpers = diag
1858                .helpers
1859                .iter()
1860                .filter(|h| is_stale_gh_credential_helper(h))
1861                .cloned()
1862                .collect();
1863            diag.message = Some(format!(
1864                "Detected stale credential helper(s) {} but auto-repair failed: {}. \
1865Run: git config --global --unset-all credential.helper; gh auth setup-git",
1866                stale_helpers.join("; "),
1867                err
1868            ));
1869            let _ = log_warn(
1870                "diag",
1871                "Failed to repair git credential.helper",
1872                Some(err.as_str()),
1873            )
1874            .await;
1875        }
1876    }
1877    Some(diag)
1878}
1879
1880async fn collect_git_hygiene(cwd: &std::path::Path) -> Option<GitHygiene> {
1881    let credential_helper = collect_and_fix_credential_helpers().await;
1882
1883    let inside = git_stdout(cwd, &["rev-parse", "--is-inside-work-tree"]).await;
1884    if inside.as_deref() != Some("true") && !cwd.join(".git").exists() {
1885        let mut notes = vec!["Not a git repository".into()];
1886        if let Some(ref cred) = credential_helper {
1887            if let Some(msg) = &cred.message {
1888                notes.push(msg.clone());
1889            }
1890        }
1891        return Some(GitHygiene {
1892            is_repo: false,
1893            branch: None,
1894            dirty: None,
1895            ahead: None,
1896            behind: None,
1897            remote: None,
1898            notes,
1899            credential_helper,
1900        });
1901    }
1902
1903    let branch = git_stdout(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
1904    let status = git_stdout(cwd, &["status", "--porcelain"]).await;
1905    let dirty = status.as_ref().map(|s| !s.is_empty());
1906    let remote = git_stdout(cwd, &["remote", "get-url", "origin"]).await;
1907    let mut notes = Vec::new();
1908    if dirty == Some(true) {
1909        notes.push("Working tree has uncommitted changes".into());
1910    }
1911    if let Some(ref cred) = credential_helper {
1912        if let Some(msg) = &cred.message {
1913            notes.push(msg.clone());
1914        } else if !cred.stale_helpers.is_empty() && !cred.fixed {
1915            notes.push(format!(
1916                "Stale git credential.helper: {}",
1917                cred.stale_helpers.join("; ")
1918            ));
1919        }
1920    }
1921    Some(GitHygiene {
1922        is_repo: true,
1923        branch,
1924        dirty,
1925        ahead: None,
1926        behind: None,
1927        remote,
1928        notes,
1929        credential_helper,
1930    })
1931}
1932
1933async fn collect_cli_auth_diagnostic() -> CliAuthDiagnostic {
1934    match crate::commands::cli_session::require_authenticated_cli_session().await {
1935        Ok(session) => CliAuthDiagnostic {
1936            logged_in: true,
1937            user_email: Some(session.user.email),
1938            user_name: Some(session.user.name),
1939            token_prefix: Some(session.token.prefix),
1940            message: "CLI session valid".into(),
1941        },
1942        Err(err) => CliAuthDiagnostic {
1943            logged_in: false,
1944            user_email: None,
1945            user_name: None,
1946            token_prefix: None,
1947            message: err,
1948        },
1949    }
1950}
1951
1952async fn collect_docker_diagnostic() -> DockerDiagnostic {
1953    let cli_present = check_program_installed("docker").await;
1954    if !cli_present {
1955        return DockerDiagnostic {
1956            cli_present: false,
1957            daemon_ok: false,
1958            version: None,
1959            compose_present: check_program_installed("docker-compose").await
1960                || check_program_installed("docker").await,
1961            note: Some("docker CLI not found on PATH".into()),
1962        };
1963    }
1964    let version = get_version("docker", &["--version"]).await;
1965    let daemon = Command::new("docker")
1966        .args(["info", "--format", "{{.ServerVersion}}"])
1967        .output()
1968        .await;
1969    let daemon_ok = daemon
1970        .as_ref()
1971        .map(|o| o.status.success())
1972        .unwrap_or(false);
1973    let compose_present = check_program_installed("docker-compose").await
1974        || Command::new("docker")
1975            .args(["compose", "version"])
1976            .output()
1977            .await
1978            .map(|o| o.status.success())
1979            .unwrap_or(false);
1980    DockerDiagnostic {
1981        cli_present: true,
1982        daemon_ok,
1983        version: version.version,
1984        compose_present,
1985        note: if daemon_ok {
1986            None
1987        } else {
1988            Some("docker daemon not reachable (is Docker running?)".into())
1989        },
1990    }
1991}
1992
1993fn build_recommendations(report: &DiagnosticReport) -> Vec<String> {
1994    let mut recs = Vec::new();
1995    if !report.connectivity {
1996        recs.push("Network offline — check interface, VPN, or firewall.".into());
1997    }
1998    if !report.project_config_present {
1999        recs.push("No XBP project config — run `xbp init` or open a project with `.xbp/xbp.yaml`.".into());
2000    }
2001    if let Some(auth) = &report.cli_auth {
2002        if !auth.logged_in {
2003            recs.push("CLI not logged in — run `xbp login` for dashboard features.".into());
2004        }
2005    }
2006    if let Some(docker) = &report.docker {
2007        if docker.cli_present && !docker.daemon_ok {
2008            recs.push("Docker CLI present but daemon unavailable — start Docker Desktop/service.".into());
2009        }
2010    }
2011    let disk_warn = report.disks.iter().any(|d| d.percent > 85.0);
2012    if disk_warn || report.system_metrics.disk_percent > 85.0 {
2013        recs.push("Disk usage high (>85%) — free space before large builds/deploys.".into());
2014    }
2015    if report.system_metrics.memory_percent > 90.0 {
2016        recs.push("Memory pressure high — close unused processes before heavy jobs.".into());
2017    }
2018    let blocked = report.port_checks.iter().filter(|p| p.is_blocked).count();
2019    if blocked > 0 {
2020        recs.push(format!(
2021            "{blocked} port(s) appear firewall-blocked — review iptables/ufw/Windows Firewall."
2022        ));
2023    }
2024    if let Some(nginx) = &report.nginx_status {
2025        if nginx.is_running && !nginx.config_valid {
2026            recs.push("Nginx running with invalid config — run `nginx -t` and `xbp nginx`.".into());
2027        }
2028    }
2029    for probe in &report.http_probes {
2030        if !probe.ok {
2031            recs.push(format!(
2032                "HTTP probe failed for `{}` ({}) — check process and port.",
2033                probe.name, probe.url
2034            ));
2035        }
2036    }
2037    for dns in &report.dns_checks {
2038        if !dns.ok {
2039            recs.push(format!(
2040                "DNS lookup failed for `{}` — check resolver / VPN split DNS.",
2041                dns.host
2042            ));
2043        }
2044    }
2045    if let Some(git) = &report.git {
2046        if git.is_repo && git.dirty == Some(true) {
2047            recs.push("Git working tree dirty — commit or stash before release/deploy.".into());
2048        }
2049        if let Some(cred) = &git.credential_helper {
2050            if !cred.stale_helpers.is_empty() && !cred.fixed {
2051                if cred.gh_path.is_none() {
2052                    recs.push(
2053                        "Stale git credential.helper points at a deleted Snap `gh`. Install native GitHub CLI (`sudo apt install gh`), then `gh auth login` and `gh auth setup-git`."
2054                            .into(),
2055                    );
2056                } else {
2057                    recs.push(
2058                        "Stale git credential.helper (deleted Snap `gh` path). Run: `git config --global --unset-all credential.helper` then `gh auth setup-git`."
2059                            .into(),
2060                    );
2061                }
2062            } else if cred.fixed {
2063                recs.push(
2064                    "Repaired git credential.helper: replaced deleted Snap `gh` path with the current `gh` binary."
2065                        .into(),
2066                );
2067            }
2068        }
2069    }
2070    if !report.installed_programs.get("git").copied().unwrap_or(false) {
2071        recs.push("git not found on PATH — install git for version/release workflows.".into());
2072    }
2073    if recs.is_empty() {
2074        recs.push("No critical issues detected — host looks ready for XBP ops.".into());
2075    }
2076    recs
2077}
2078
2079fn build_scorecard(report: &DiagnosticReport) -> DiagScorecard {
2080    let mut healthy = 0usize;
2081    let mut warnings = 0usize;
2082    let mut failures = 0usize;
2083
2084    if report.connectivity {
2085        healthy += 1;
2086    } else {
2087        failures += 1;
2088    }
2089    if report.project_config_present {
2090        healthy += 1;
2091    } else {
2092        warnings += 1;
2093    }
2094    let path_fail = report
2095        .path_permission_checks
2096        .iter()
2097        .filter(|p| !p.ok)
2098        .count();
2099    if path_fail == 0 {
2100        healthy += 1;
2101    } else {
2102        warnings += 1;
2103    }
2104    let blocked = report.port_checks.iter().filter(|p| p.is_blocked).count();
2105    if blocked == 0 {
2106        healthy += 1;
2107    } else {
2108        failures += 1;
2109    }
2110    if report.system_metrics.disk_percent > 90.0 {
2111        failures += 1;
2112    } else if report.system_metrics.disk_percent > 80.0 {
2113        warnings += 1;
2114    } else {
2115        healthy += 1;
2116    }
2117    if let Some(auth) = &report.cli_auth {
2118        if auth.logged_in {
2119            healthy += 1;
2120        } else {
2121            warnings += 1;
2122        }
2123    }
2124    let http_fail = report.http_probes.iter().filter(|p| !p.ok).count();
2125    if !report.http_probes.is_empty() {
2126        if http_fail == 0 {
2127            healthy += 1;
2128        } else if http_fail < report.http_probes.len() {
2129            warnings += 1;
2130        } else {
2131            failures += 1;
2132        }
2133    }
2134    if let Some(git) = &report.git {
2135        if let Some(cred) = &git.credential_helper {
2136            if !cred.stale_helpers.is_empty() && !cred.fixed {
2137                failures += 1;
2138            } else if cred.fixed {
2139                warnings += 1;
2140            } else if cred.message.is_some() {
2141                healthy += 1;
2142            }
2143        }
2144    }
2145    let grade = if failures > 0 {
2146        "FAIL"
2147    } else if warnings > 0 {
2148        "WARN"
2149    } else {
2150        "OK"
2151    }
2152    .to_string();
2153    DiagScorecard {
2154        healthy,
2155        warnings,
2156        failures,
2157        grade,
2158    }
2159}
2160
2161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2162enum SectionState {
2163    Healthy,
2164    Warn,
2165    Fail,
2166    Info,
2167}
2168
2169fn section_badge(state: SectionState) -> colored::ColoredString {
2170    match state {
2171        SectionState::Healthy => "OK".green().bold(),
2172        SectionState::Warn => "WARN".yellow().bold(),
2173        SectionState::Fail => "FAIL".red().bold(),
2174        SectionState::Info => "INFO".cyan().bold(),
2175    }
2176}
2177
2178fn print_section_header(title: &str, state: SectionState, summary: &str) {
2179    println!(
2180        "\n{} {} {}",
2181        section_badge(state),
2182        title.bright_white().bold(),
2183        summary.bright_black()
2184    );
2185}
2186
2187fn diag_bool(value: bool) -> colored::ColoredString {
2188    if value {
2189        "true".green()
2190    } else {
2191        "false".red()
2192    }
2193}
2194
2195fn diag_bool_fragment(label: &str, value: bool) -> String {
2196    format!("{}={}", label, diag_bool(value))
2197}
2198
2199fn diag_ratio(passing: usize, total: usize) -> String {
2200    format!(
2201        "{}{}",
2202        passing.to_string().green(),
2203        format!("/{}", total).bright_white()
2204    )
2205}
2206
2207fn color_service_status(status: &str) -> colored::ColoredString {
2208    let lower = status.to_ascii_lowercase();
2209    if lower.contains("running") || lower.contains("active") || lower.contains("online") {
2210        status.green()
2211    } else if lower.contains("stopped") || lower.contains("failed") || lower.contains("error") {
2212        status.red()
2213    } else if lower.contains("degraded") || lower.contains("warn") {
2214        status.yellow()
2215    } else {
2216        status.bright_white()
2217    }
2218}
2219
2220fn render_feature_flags(flags: &HashMap<String, bool>) -> String {
2221    let mut items = flags
2222        .iter()
2223        .map(|(name, value)| format!("{}={}", name, diag_bool(*value)))
2224        .collect::<Vec<_>>();
2225    items.sort();
2226    items.join("  ")
2227}
2228
2229fn summarize_path_permissions(report: &DiagnosticReport) -> (usize, usize) {
2230    let total = report.path_permission_checks.len();
2231    let passing = report
2232        .path_permission_checks
2233        .iter()
2234        .filter(|item| item.ok)
2235        .count();
2236    (passing, total)
2237}
2238
2239fn summarize_port_checks(report: &DiagnosticReport) -> (usize, usize, usize) {
2240    let total = report.port_checks.len();
2241    let open = report
2242        .port_checks
2243        .iter()
2244        .filter(|item| item.is_open)
2245        .count();
2246    let blocked = report
2247        .port_checks
2248        .iter()
2249        .filter(|item| item.is_blocked)
2250        .count();
2251    (open, blocked, total)
2252}
2253
2254pub async fn print_diagnostic_report(report: &DiagnosticReport) {
2255    let feature_flags = render_feature_flags(&report.feature_flags);
2256    let grade_state = match report.scorecard.grade.as_str() {
2257        "OK" => SectionState::Healthy,
2258        "WARN" => SectionState::Warn,
2259        _ => SectionState::Fail,
2260    };
2261    print_section_header(
2262        "Scorecard",
2263        grade_state,
2264        &format!(
2265            "grade={} | healthy={} warn={} fail={}",
2266            report.scorecard.grade,
2267            report.scorecard.healthy,
2268            report.scorecard.warnings,
2269            report.scorecard.failures
2270        ),
2271    );
2272    println!(
2273        "  {} {}",
2274        "Working directory:".bright_white(),
2275        report.cwd.bright_cyan()
2276    );
2277    if let Some(host) = &report.hostname {
2278        println!("  {} {}", "Hostname:".bright_white(), host);
2279    }
2280    if !report.local_ips.is_empty() {
2281        println!(
2282            "  {} {}",
2283            "Local IPs:".bright_white(),
2284            report.local_ips.join(", ")
2285        );
2286    }
2287
2288    let env_summary = format!(
2289        "v{} | {} | connectivity {}",
2290        report.xbp_cli_version,
2291        feature_flags,
2292        if report.connectivity {
2293            "online"
2294        } else {
2295            "offline"
2296        }
2297    );
2298    print_section_header(
2299        "Environment",
2300        if report.connectivity {
2301            SectionState::Healthy
2302        } else {
2303            SectionState::Warn
2304        },
2305        &env_summary,
2306    );
2307
2308    println!(
2309        "  {} {}",
2310        "XBP version:".bright_white(),
2311        report.xbp_cli_version.bright_cyan()
2312    );
2313    println!("  {} {}", "Feature flags:".bright_white(), feature_flags);
2314
2315    if let Some(os) = &report.os {
2316        let os_line = [
2317            os.name.clone().unwrap_or_else(|| "Unknown OS".to_string()),
2318            os.version.clone().unwrap_or_default(),
2319        ]
2320        .into_iter()
2321        .filter(|s| !s.is_empty())
2322        .collect::<Vec<_>>()
2323        .join(" ");
2324        println!("  {} {}", "OS:".bright_white(), os_line);
2325        if let Some(kernel) = &os.kernel_version {
2326            println!("  {} {}", "Kernel:".bright_white(), kernel);
2327        }
2328        if let Some(arch) = &os.arch {
2329            println!("  {} {}", "Arch:".bright_white(), arch);
2330        }
2331    }
2332
2333    if let Some(cpu) = &report.cpu {
2334        println!(
2335            "  {} {} ({} cores, {:.1}% avg)",
2336            "CPU:".bright_white(),
2337            cpu.brand.clone().unwrap_or_else(|| "Unknown".into()),
2338            cpu.cores,
2339            cpu.usage
2340        );
2341    }
2342
2343    if let Some(shell) = &report.shell {
2344        if let Some(s) = &shell.shell {
2345            println!("  {} {}", "Shell:".bright_white(), s);
2346        }
2347        if let Some(t) = &shell.term {
2348            println!("  {} {}", "TERM:".bright_white(), t);
2349        }
2350    }
2351
2352    println!(
2353        "  {} {}",
2354        "Connectivity:".bright_white(),
2355        if report.connectivity {
2356            "online".green()
2357        } else {
2358            "offline".red()
2359        }
2360    );
2361
2362    if let Some(speed) = &report.internet_speed {
2363        println!("  {} {:.2} ms", "Ping:".bright_white(), speed.ping_ms);
2364        println!(
2365            "  {} {:.2} Mbps",
2366            "Download:".bright_white(),
2367            speed.download_mbps
2368        );
2369        if speed.upload_mbps > 0.0 {
2370            println!(
2371                "  {} {:.2} Mbps",
2372                "Upload:".bright_white(),
2373                speed.upload_mbps
2374            );
2375        }
2376    }
2377
2378    if !report.gpu_candidates.is_empty() {
2379        print_section_header(
2380            "GPU candidates",
2381            SectionState::Info,
2382            &format!("{} candidate(s)", report.gpu_candidates.len()),
2383        );
2384        for line in &report.gpu_candidates {
2385            println!("  {}", line);
2386        }
2387    }
2388
2389    if !report.disks.is_empty() {
2390        print_section_header(
2391            "Disks",
2392            SectionState::Info,
2393            &format!("{} mount(s)", report.disks.len()),
2394        );
2395        for disk in &report.disks {
2396            let current = if disk.is_current { " *" } else { "" };
2397            let fs = disk.fs.clone().unwrap_or_else(|| "-".to_string());
2398            println!(
2399                "  {}{}  {} {:>5.1}%  {} ({} / {} GB)",
2400                disk.mount,
2401                current,
2402                fs,
2403                disk.percent,
2404                disk.bar,
2405                disk.used / 1024 / 1024 / 1024,
2406                disk.total / 1024 / 1024 / 1024
2407            );
2408        }
2409        println!("  {} current mount", "*".bright_cyan());
2410    }
2411
2412    print_section_header(
2413        "System metrics",
2414        SectionState::Info,
2415        &format!("{} processes", report.system_metrics.process_count),
2416    );
2417    let metrics = &report.system_metrics;
2418
2419    let cpu_color = if metrics.cpu_usage > 80.0 {
2420        "red"
2421    } else if metrics.cpu_usage > 50.0 {
2422        "yellow"
2423    } else {
2424        "green"
2425    };
2426    println!(
2427        "  {} {:.1}%",
2428        "CPU usage:".bright_white(),
2429        format!("{}", metrics.cpu_usage).color(cpu_color)
2430    );
2431
2432    let mem_color = if metrics.memory_percent > 80.0 {
2433        "red"
2434    } else if metrics.memory_percent > 50.0 {
2435        "yellow"
2436    } else {
2437        "green"
2438    };
2439    println!(
2440        "  {} {:.1}% ({} MB / {} MB)",
2441        "Memory:".bright_white(),
2442        format!("{}", metrics.memory_percent).color(mem_color),
2443        metrics.memory_used / 1024 / 1024,
2444        metrics.memory_total / 1024 / 1024
2445    );
2446
2447    let disk_color = if metrics.disk_percent > 80.0 {
2448        "red"
2449    } else if metrics.disk_percent > 50.0 {
2450        "yellow"
2451    } else {
2452        "green"
2453    };
2454    println!(
2455        "  {} {:.1}% ({} GB / {} GB)",
2456        "Disk:".bright_white(),
2457        format!("{}", metrics.disk_percent).color(disk_color),
2458        metrics.disk_used / 1024 / 1024 / 1024,
2459        metrics.disk_total / 1024 / 1024 / 1024
2460    );
2461
2462    println!(
2463        "  {} {} MB down / {} MB up",
2464        "Network:".bright_white(),
2465        metrics.network_rx / 1024 / 1024,
2466        metrics.network_tx / 1024 / 1024
2467    );
2468
2469    let uptime_hours = metrics.uptime / 3600;
2470    let uptime_minutes = (metrics.uptime % 3600) / 60;
2471    println!(
2472        "  {} {}h {}m",
2473        "Uptime:".bright_white(),
2474        uptime_hours,
2475        uptime_minutes
2476    );
2477    println!(
2478        "  {} {}",
2479        "Processes:".bright_white(),
2480        metrics.process_count
2481    );
2482
2483    let (open_ports, blocked_ports, _) = summarize_port_checks(report);
2484    let runtime_state = if blocked_ports == 0 {
2485        SectionState::Healthy
2486    } else {
2487        SectionState::Fail
2488    };
2489    print_section_header(
2490        "Runtime",
2491        runtime_state,
2492        &format!(
2493            "services={} | pm2={} | ports={} open / {} blocked",
2494            report.service_statuses.len(),
2495            report.pm2_process_count.unwrap_or(0),
2496            open_ports,
2497            blocked_ports
2498        ),
2499    );
2500
2501    if let Some(proxy) = &report.proxy_detection {
2502        println!("  {}", "Proxy stack:".bright_white());
2503        if let Some(n) = proxy.nginx_sites_available {
2504            println!("    {} {}", "sites-available:".bright_white(), n);
2505        }
2506        if let Some(v) = proxy.nginx_active {
2507            println!("    {} {}", "nginx active:".bright_white(), diag_bool(v));
2508        }
2509        if let Some(v) = proxy.traefik_active {
2510            println!("    {} {}", "traefik active:".bright_white(), diag_bool(v));
2511        }
2512        if let Some(v) = proxy.apache_active {
2513            println!("    {} {}", "apache active:".bright_white(), diag_bool(v));
2514        }
2515    }
2516
2517    if let Some(count) = report.pm2_process_count {
2518        println!("  {} {}", "PM2 process count:".bright_white(), count);
2519    }
2520
2521    if !report.service_statuses.is_empty() {
2522        let mut items: Vec<_> = report
2523            .service_statuses
2524            .iter()
2525            // On non-systemd hosts almost everything is "unknown" — only show known answers.
2526            .filter(|(_, status)| status.as_str() != "unknown")
2527            .collect();
2528        if items.is_empty() {
2529            println!(
2530                "  {} {}",
2531                "Services:".bright_white(),
2532                "(no systemd units detected on this host)".bright_black()
2533            );
2534        } else {
2535            println!("  {}", "Services:".bright_white());
2536            items.sort_by_key(|(k, _)| *k);
2537            for (name, status) in items {
2538                println!("    {:15} {}", name, color_service_status(status));
2539            }
2540        }
2541    }
2542
2543    if let Some(nginx) = &report.nginx_status {
2544        println!("  {}", "Nginx:".bright_white());
2545        println!(
2546            "    {} {}",
2547            if nginx.is_running {
2548                "OK".green()
2549            } else {
2550                "NO".red()
2551            },
2552            if nginx.is_running {
2553                "Running".green()
2554            } else {
2555                "Stopped".red()
2556            }
2557        );
2558        println!(
2559            "    {} {}",
2560            if nginx.is_enabled {
2561                "OK".green()
2562            } else {
2563                "NO".red()
2564            },
2565            if nginx.is_enabled {
2566                "Enabled".green()
2567            } else {
2568                "Disabled".red()
2569            }
2570        );
2571        println!(
2572            "    {} {}",
2573            if nginx.config_valid {
2574                "OK".green()
2575            } else {
2576                "NO".red()
2577            },
2578            if nginx.config_valid {
2579                "Config valid".green()
2580            } else {
2581                "Config invalid".red()
2582            }
2583        );
2584    }
2585
2586    if !report.port_checks.is_empty() {
2587        println!("  {}", "Port coverage:".bright_white());
2588        let (xbp_ports, other_ports): (Vec<_>, Vec<_>) = report
2589            .port_checks
2590            .iter()
2591            .partition(|port_check| !port_check.xbp_projects.is_empty());
2592
2593        for port_check in xbp_ports.iter().chain(other_ports.iter()) {
2594            let status_icon = if port_check.is_open {
2595                "OK".green()
2596            } else {
2597                "NO".red()
2598            };
2599            let blocked_text = if port_check.is_blocked {
2600                " (BLOCKED)".red().to_string()
2601            } else {
2602                String::new()
2603            };
2604            let xbp_suffix = if port_check.xbp_projects.is_empty() {
2605                String::new()
2606            } else {
2607                format!(" [XBP: {}]", port_check.xbp_projects.join(", "))
2608            };
2609            let line = format!(
2610                "    {} Port {}: {}{}{}",
2611                status_icon,
2612                port_check.port,
2613                if port_check.is_open {
2614                    "Available".green().to_string()
2615                } else {
2616                    "In use".red().to_string()
2617                },
2618                blocked_text,
2619                xbp_suffix
2620            );
2621
2622            if port_check.xbp_projects.is_empty() {
2623                println!("{}", line);
2624            } else {
2625                println!("{}", line.bright_magenta());
2626            }
2627        }
2628    }
2629
2630    let (path_passing, path_total) = summarize_path_permissions(report);
2631    let project_state = if report.project_config_present || !report.provider_manifests.is_empty() {
2632        if path_passing == path_total {
2633            SectionState::Healthy
2634        } else {
2635            SectionState::Warn
2636        }
2637    } else {
2638        SectionState::Info
2639    };
2640    print_section_header(
2641        "Project",
2642        project_state,
2643        &format!(
2644            "config={} | services={} | manifests={} | paths={}",
2645            diag_bool(report.project_config_present),
2646            report.configured_services.len(),
2647            report.provider_manifests.len(),
2648            diag_ratio(path_passing, path_total)
2649        ),
2650    );
2651
2652    if !report.configured_services.is_empty() {
2653        println!(
2654            "  {} {}",
2655            "Configured services:".bright_white(),
2656            report.configured_services.join(", ").bright_white()
2657        );
2658    }
2659
2660    if !report.provider_manifests.is_empty() {
2661        println!(
2662            "  {} {}",
2663            "Provider manifests:".bright_white(),
2664            report.provider_manifests.join(", ").bright_white()
2665        );
2666    }
2667
2668    if !report.path_permission_checks.is_empty() {
2669        println!(
2670            "  {} {}",
2671            "Checks passing:".bright_white(),
2672            diag_ratio(path_passing, path_total)
2673        );
2674
2675        for item in &report.path_permission_checks {
2676            let icon = if item.ok { "OK".green() } else { "NO".red() };
2677            println!(
2678                "    {} {} [{}]",
2679                icon,
2680                item.path.bright_white(),
2681                item.required
2682            );
2683            println!("      purpose: {}", item.purpose);
2684            println!(
2685                "      status: {} {} {} {}",
2686                diag_bool_fragment("exists", item.exists),
2687                diag_bool_fragment("read", item.readable),
2688                diag_bool_fragment("write", item.writable),
2689                diag_bool_fragment("create", item.creatable)
2690            );
2691            if let Some(msg) = &item.message {
2692                println!("      note: {}", msg);
2693            }
2694        }
2695    }
2696
2697    print_section_header(
2698        "XBP readiness",
2699        if report.project_config_present {
2700            SectionState::Healthy
2701        } else {
2702            SectionState::Warn
2703        },
2704        &format!(
2705            "version={} | feature flags={} | manifests={}",
2706            report.xbp_cli_version,
2707            report.feature_flags.len(),
2708            report.provider_manifests.len()
2709        ),
2710    );
2711    println!(
2712        "  {} {}",
2713        "CLI version:".bright_white(),
2714        report.xbp_cli_version.bright_cyan()
2715    );
2716    println!("  {} {}", "Feature flags:".bright_white(), feature_flags);
2717    println!(
2718        "  {} {}",
2719        "Project config:".bright_white(),
2720        if report.project_config_present {
2721            "detected".green()
2722        } else {
2723            "missing".red()
2724        }
2725    );
2726    println!(
2727        "  {} {}",
2728        "Provider manifests:".bright_white(),
2729        report.provider_manifests.len()
2730    );
2731
2732    if !report.dns_checks.is_empty() {
2733        let fail = report.dns_checks.iter().filter(|d| !d.ok).count();
2734        print_section_header(
2735            "DNS",
2736            if fail == 0 {
2737                SectionState::Healthy
2738            } else {
2739                SectionState::Warn
2740            },
2741            &format!("{} host(s), {} failed", report.dns_checks.len(), fail),
2742        );
2743        for dns in &report.dns_checks {
2744            let icon = if dns.ok { "OK".green() } else { "NO".red() };
2745            println!(
2746                "  {} {} → {}",
2747                icon,
2748                dns.host.bright_white(),
2749                if dns.ok {
2750                    dns.addresses.join(", ")
2751                } else {
2752                    dns.error.clone().unwrap_or_else(|| "lookup failed".into())
2753                }
2754            );
2755        }
2756    }
2757
2758    if !report.http_probes.is_empty() {
2759        let ok = report.http_probes.iter().filter(|p| p.ok).count();
2760        print_section_header(
2761            "HTTP probes",
2762            if ok == report.http_probes.len() {
2763                SectionState::Healthy
2764            } else if ok == 0 {
2765                SectionState::Fail
2766            } else {
2767                SectionState::Warn
2768            },
2769            &format!("{ok}/{} responding", report.http_probes.len()),
2770        );
2771        for probe in &report.http_probes {
2772            let icon = if probe.ok { "OK".green() } else { "NO".red() };
2773            let status = probe
2774                .status
2775                .map(|s| s.to_string())
2776                .unwrap_or_else(|| "-".into());
2777            let latency = probe
2778                .latency_ms
2779                .map(|ms| format!("{ms}ms"))
2780                .unwrap_or_else(|| "-".into());
2781            println!(
2782                "  {} {:16} {}  status={} latency={}",
2783                icon,
2784                probe.name.bright_white(),
2785                probe.url.bright_black(),
2786                status,
2787                latency
2788            );
2789            if let Some(err) = &probe.error {
2790                println!("      {}", err.yellow());
2791            }
2792        }
2793    }
2794
2795    if let Some(git) = &report.git {
2796        let cred_stale_unfixed = git
2797            .credential_helper
2798            .as_ref()
2799            .map(|c| !c.stale_helpers.is_empty() && !c.fixed)
2800            .unwrap_or(false);
2801        let cred_fixed = git
2802            .credential_helper
2803            .as_ref()
2804            .map(|c| c.fixed)
2805            .unwrap_or(false);
2806        print_section_header(
2807            "Git",
2808            if !git.is_repo && !cred_stale_unfixed {
2809                SectionState::Info
2810            } else if cred_stale_unfixed {
2811                SectionState::Fail
2812            } else if git.dirty == Some(true) || cred_fixed {
2813                SectionState::Warn
2814            } else {
2815                SectionState::Healthy
2816            },
2817            if git.is_repo {
2818                git.branch.as_deref().unwrap_or("detached")
2819            } else {
2820                "not a repo"
2821            },
2822        );
2823        if git.is_repo {
2824            if let Some(branch) = &git.branch {
2825                println!("  {} {}", "Branch:".bright_white(), branch);
2826            }
2827            if let Some(dirty) = git.dirty {
2828                println!(
2829                    "  {} {}",
2830                    "Dirty:".bright_white(),
2831                    if dirty {
2832                        "yes".yellow()
2833                    } else {
2834                        "no".green()
2835                    }
2836                );
2837            }
2838            if let Some(remote) = &git.remote {
2839                println!("  {} {}", "Origin:".bright_white(), remote);
2840            }
2841        }
2842        if let Some(cred) = &git.credential_helper {
2843            if !cred.helpers.is_empty() {
2844                println!(
2845                    "  {} {}",
2846                    "credential.helper:".bright_white(),
2847                    cred.helpers.join(" | ")
2848                );
2849            }
2850            if let Some(gh) = &cred.gh_path {
2851                println!("  {} {}", "gh:".bright_white(), gh);
2852            }
2853            if cred.fixed {
2854                println!(
2855                    "  {} {}",
2856                    "credential.helper fix:".bright_white(),
2857                    "applied".green()
2858                );
2859            } else if !cred.stale_helpers.is_empty() {
2860                println!(
2861                    "  {} {}",
2862                    "stale helper(s):".bright_white(),
2863                    cred.stale_helpers.join(" | ").red()
2864                );
2865            }
2866        }
2867        for note in &git.notes {
2868            let color_note = if note.to_ascii_lowercase().contains("repaired")
2869                || note.to_ascii_lowercase().contains("healthy")
2870            {
2871                note.green()
2872            } else {
2873                note.yellow()
2874            };
2875            println!("  {} {}", "Note:".bright_white(), color_note);
2876        }
2877    }
2878
2879    if let Some(auth) = &report.cli_auth {
2880        print_section_header(
2881            "CLI auth",
2882            if auth.logged_in {
2883                SectionState::Healthy
2884            } else {
2885                SectionState::Warn
2886            },
2887            if auth.logged_in { "signed in" } else { "signed out" },
2888        );
2889        println!("  {} {}", "Status:".bright_white(), auth.message);
2890        if let Some(email) = &auth.user_email {
2891            println!(
2892                "  {} {} ({})",
2893                "User:".bright_white(),
2894                email,
2895                auth.user_name.clone().unwrap_or_default()
2896            );
2897        }
2898        if let Some(prefix) = &auth.token_prefix {
2899            println!("  {} {}", "Token:".bright_white(), prefix);
2900        }
2901    }
2902
2903    if let Some(docker) = &report.docker {
2904        print_section_header(
2905            "Docker",
2906            if !docker.cli_present {
2907                SectionState::Info
2908            } else if docker.daemon_ok {
2909                SectionState::Healthy
2910            } else {
2911                SectionState::Warn
2912            },
2913            if docker.daemon_ok {
2914                "daemon ok"
2915            } else if docker.cli_present {
2916                "daemon down"
2917            } else {
2918                "not installed"
2919            },
2920        );
2921        println!(
2922            "  {} cli={} daemon={} compose={}",
2923            "Status:".bright_white(),
2924            diag_bool(docker.cli_present),
2925            diag_bool(docker.daemon_ok),
2926            diag_bool(docker.compose_present)
2927        );
2928        if let Some(v) = &docker.version {
2929            println!("  {} {}", "Version:".bright_white(), v);
2930        }
2931        if let Some(note) = &docker.note {
2932            println!("  {} {}", "Note:".bright_white(), note.yellow());
2933        }
2934    }
2935
2936    if !report.env_files.is_empty() {
2937        print_section_header(
2938            "Env files",
2939            SectionState::Info,
2940            &format!("{} found", report.env_files.len()),
2941        );
2942        for f in &report.env_files {
2943            println!("  {}", f.bright_cyan());
2944        }
2945    }
2946
2947    if !report.network_interfaces.is_empty() {
2948        print_section_header(
2949            "Network interfaces",
2950            SectionState::Info,
2951            &format!("{} iface(s)", report.network_interfaces.len()),
2952        );
2953        for iface in report.network_interfaces.iter().take(12) {
2954            println!(
2955                "  {:16} rx={} MB  tx={} MB",
2956                iface.name,
2957                iface.rx_bytes / 1024 / 1024,
2958                iface.tx_bytes / 1024 / 1024
2959            );
2960        }
2961    }
2962
2963    if !report.tool_versions.is_empty() {
2964        let present = report
2965            .tool_versions
2966            .values()
2967            .filter(|t| t.present)
2968            .count();
2969        print_section_header(
2970            "Tool versions",
2971            SectionState::Info,
2972            &format!("{present}/{} present", report.tool_versions.len()),
2973        );
2974        let mut items: Vec<_> = report.tool_versions.iter().collect();
2975        items.sort_by_key(|(k, _)| *k);
2976        for (name, tool) in items {
2977            if !tool.present {
2978                continue;
2979            }
2980            println!(
2981                "  {:14} {}",
2982                name.bright_white(),
2983                tool.version.clone().unwrap_or_else(|| "present".into())
2984            );
2985        }
2986    }
2987
2988    if !report.installed_programs.is_empty() {
2989        let mut missing: Vec<_> = report
2990            .installed_programs
2991            .iter()
2992            .filter(|(_, ok)| !**ok)
2993            .map(|(k, _)| k.clone())
2994            .collect();
2995        missing.sort();
2996        let present = report.installed_programs.len() - missing.len();
2997        print_section_header(
2998            "Toolchain PATH",
2999            if missing.len() > report.installed_programs.len() / 2 {
3000                SectionState::Warn
3001            } else {
3002                SectionState::Info
3003            },
3004            &format!("{present} present / {} missing", missing.len()),
3005        );
3006        if !missing.is_empty() {
3007            println!(
3008                "  {} {}",
3009                "Missing:".bright_white(),
3010                missing.join(", ").yellow()
3011            );
3012        }
3013    }
3014
3015    if !report.recommendations.is_empty() {
3016        print_section_header(
3017            "Recommendations",
3018            SectionState::Info,
3019            &format!("{} tip(s)", report.recommendations.len()),
3020        );
3021        for (i, rec) in report.recommendations.iter().enumerate() {
3022            println!("  {}. {}", i + 1, rec);
3023        }
3024    }
3025
3026    if let Some(watch) = &report.worktree_watch {
3027        let watch_state = if watch.branch_spool_exists || watch.watcher_running {
3028            SectionState::Healthy
3029        } else if watch.notes.iter().any(|note| note.contains("Failed")) {
3030            SectionState::Warn
3031        } else {
3032            SectionState::Info
3033        };
3034        let summary = match (
3035            watch.repo_owner.as_deref(),
3036            watch.repo_name.as_deref(),
3037            watch.branch.as_deref(),
3038        ) {
3039            (Some(owner), Some(name), Some(branch)) => format!(
3040                "runtime={} | {}/{} @ {} | events={} commits={}",
3041                watch.runtime_key, owner, name, branch, watch.event_jsonl_files, watch.commit_jsonl_files
3042            ),
3043            _ => format!(
3044                "runtime={} | global home only | wsl={}",
3045                watch.runtime_key,
3046                diag_bool(watch.is_wsl)
3047            ),
3048        };
3049        print_section_header("Worktree watch", watch_state, &summary);
3050
3051        println!(
3052            "  {} {}",
3053            "Global XBP home:".bright_white(),
3054            watch.global_xbp_root.bright_cyan()
3055        );
3056        println!(
3057            "  {} {}",
3058            "Mutations root:".bright_white(),
3059            watch.mutations_root
3060        );
3061        println!(
3062            "  {} {} | {} {}",
3063            "Platform:".bright_white(),
3064            watch.platform,
3065            "runtime:".bright_white(),
3066            watch.runtime_key.bright_cyan()
3067        );
3068        println!(
3069            "  {} {}",
3070            "WSL:".bright_white(),
3071            diag_bool(watch.is_wsl)
3072        );
3073
3074        if let (Some(owner), Some(name), Some(branch)) = (
3075            watch.repo_owner.as_deref(),
3076            watch.repo_name.as_deref(),
3077            watch.branch.as_deref(),
3078        ) {
3079            println!(
3080                "  {} {}/{} ({})",
3081                "Repository:".bright_white(),
3082                owner,
3083                name,
3084                branch
3085            );
3086        }
3087
3088        if let Some(spool) = &watch.branch_spool {
3089            println!(
3090                "  {} {} {}",
3091                "Branch spool:".bright_white(),
3092                spool,
3093                if watch.branch_spool_exists {
3094                    "(exists)".green()
3095                } else {
3096                    "(missing)".yellow()
3097                }
3098            );
3099        }
3100
3101        println!(
3102            "  {} events={} commits={}",
3103            "Spool files:".bright_white(),
3104            watch.event_jsonl_files,
3105            watch.commit_jsonl_files
3106        );
3107
3108        if let Some(state_path) = &watch.watcher_state_path {
3109            println!(
3110                "  {} {} | present={} running={}",
3111                "Watcher state:".bright_white(),
3112                state_path,
3113                diag_bool(watch.watcher_state_present),
3114                diag_bool(watch.watcher_running)
3115            );
3116        }
3117
3118        if !watch.alternate_spool_roots.is_empty() {
3119            println!("  {}", "Alternate spools (legacy / shared):".bright_white());
3120            for root in &watch.alternate_spool_roots {
3121                println!("    {}", root);
3122            }
3123        }
3124
3125        if !watch.notes.is_empty() {
3126            println!("  {}", "Notes:".bright_white());
3127            for note in &watch.notes {
3128                println!("    - {}", note);
3129            }
3130        }
3131    }
3132
3133    if let Some(codetime) = &report.codetime {
3134        print_section_header(
3135            "CodeTime inventory",
3136            if codetime.refreshed {
3137                SectionState::Healthy
3138            } else {
3139                SectionState::Info
3140            },
3141            &format!(
3142                "saved={} | refreshed={}",
3143                codetime.saved_config_path,
3144                diag_bool(codetime.refreshed)
3145            ),
3146        );
3147        println!(
3148            "  {} {}",
3149            "Saved in:".bright_white(),
3150            codetime.saved_config_path
3151        );
3152        println!(
3153            "  {} {}",
3154            "Refreshed:".bright_white(),
3155            if codetime.refreshed {
3156                "cached"
3157            } else {
3158                "fresh"
3159            }
3160        );
3161        if let Some(user) = &codetime.inventory.system_user {
3162            println!("  {} {}", "User:".bright_white(), user);
3163        }
3164        if let Some(host) = &codetime.inventory.host_name {
3165            println!("  {} {}", "Host:".bright_white(), host);
3166        }
3167        println!(
3168            "  {} {} / {}",
3169            "Platform:".bright_white(),
3170            codetime.inventory.platform,
3171            codetime.inventory.architecture
3172        );
3173        if let Some(os) = &codetime.inventory.operating_system {
3174            let os_label = os
3175                .long_version
3176                .clone()
3177                .or_else(|| match (&os.name, &os.version) {
3178                    (Some(name), Some(version)) => Some(format!("{} {}", name, version)),
3179                    (Some(name), None) => Some(name.clone()),
3180                    (None, Some(version)) => Some(version.clone()),
3181                    (None, None) => None,
3182                });
3183            if let Some(os_label) = os_label {
3184                println!("  {} {}", "Operating system:".bright_white(), os_label);
3185            }
3186            if let Some(kernel_version) = &os.kernel_version {
3187                println!("  {} {}", "Kernel:".bright_white(), kernel_version);
3188            }
3189        }
3190        if !codetime.inventory.drives.is_empty() {
3191            let drives = codetime
3192                .inventory
3193                .drives
3194                .iter()
3195                .map(|drive| match &drive.file_system {
3196                    Some(file_system) => format!("{} ({})", drive.mount, file_system),
3197                    None => drive.mount.clone(),
3198                })
3199                .collect::<Vec<_>>()
3200                .join(", ");
3201            println!("  {} {}", "Drives:".bright_white(), drives);
3202        }
3203        if let Some(home) = codetime.inventory.standard_paths.get("home") {
3204            println!("  {} {}", "Home:".bright_white(), home);
3205        }
3206        if let Some(xbp_root) = codetime.inventory.standard_paths.get("xbp_global_root") {
3207            println!("  {} {}", "XBP root:".bright_white(), xbp_root);
3208        }
3209        if let Some(cursor_root) = codetime.inventory.standard_paths.get("cursor_roaming") {
3210            println!("  {} {}", "Cursor root:".bright_white(), cursor_root);
3211        }
3212
3213        let installed_tool_labels = codetime
3214            .inventory
3215            .installed_tools
3216            .iter()
3217            .filter(|tool| tool.present)
3218            .map(|tool| match &tool.version {
3219                Some(version) => format!("{} ({})", tool.name, version),
3220                None => tool.name.clone(),
3221            })
3222            .collect::<Vec<_>>();
3223        println!(
3224            "  {} {}",
3225            "Installed tools:".bright_white(),
3226            if installed_tool_labels.is_empty() {
3227                "0".to_string()
3228            } else {
3229                format!(
3230                    "{} ({})",
3231                    installed_tool_labels.len(),
3232                    format_sample_list(&installed_tool_labels, 8)
3233                )
3234            }
3235        );
3236
3237        let identity_labels = codetime
3238            .inventory
3239            .git_identities
3240            .iter()
3241            .map(
3242                |identity| match (&identity.user_name, &identity.user_email) {
3243                    (Some(name), Some(email)) => format!("{} <{}>", name, email),
3244                    (Some(name), None) => name.clone(),
3245                    (None, Some(email)) => email.clone(),
3246                    (None, None) => identity.scope.clone(),
3247                },
3248            )
3249            .collect::<Vec<_>>();
3250        if !identity_labels.is_empty() {
3251            println!(
3252                "  {} {}",
3253                "Git identities:".bright_white(),
3254                format_sample_list(&identity_labels, 4)
3255            );
3256        }
3257
3258        let repo_names = codetime
3259            .inventory
3260            .github_repos
3261            .iter()
3262            .map(|repo| repo.full_name.clone())
3263            .collect::<Vec<_>>();
3264        println!(
3265            "  {} {}",
3266            "Seen GitHub repos:".bright_white(),
3267            if repo_names.is_empty() {
3268                "0".to_string()
3269            } else {
3270                format!(
3271                    "{} ({})",
3272                    repo_names.len(),
3273                    format_sample_list(&repo_names, 5)
3274                )
3275            }
3276        );
3277
3278        let project_names = codetime
3279            .inventory
3280            .xbp_projects
3281            .iter()
3282            .map(|project| project.name.clone())
3283            .collect::<Vec<_>>();
3284        println!(
3285            "  {} {}",
3286            "Seen XBP projects:".bright_white(),
3287            if project_names.is_empty() {
3288                "0".to_string()
3289            } else {
3290                format!(
3291                    "{} ({})",
3292                    project_names.len(),
3293                    format_sample_list(&project_names, 5)
3294                )
3295            }
3296        );
3297
3298        if let Some(cursor) = &codetime.inventory.cursor {
3299            println!(
3300                "  {} {}",
3301                "Cursor present:".bright_white(),
3302                diag_bool(cursor.exists)
3303            );
3304            println!(
3305                "  {} workspaceStorage={} extensions={} logs={}",
3306                "Cursor details:".bright_white(),
3307                cursor.workspace_storage_entries,
3308                cursor.extensions_entries,
3309                diag_bool(cursor.logs_exists)
3310            );
3311            if let Some(note) = &cursor.note {
3312                println!("  {} {}", "Cursor note:".bright_white(), note);
3313            }
3314        }
3315    }
3316
3317    if !report.clipboard_tools.is_empty() {
3318        println!("\n{}", "Clipboard tools".bright_yellow().bold());
3319        let mut items: Vec<_> = report.clipboard_tools.iter().collect();
3320        items.sort_by_key(|(k, _)| *k);
3321        for (name, value) in items {
3322            println!("  {:10} {}", name, diag_bool(*value));
3323        }
3324    }
3325
3326    if !report.installed_programs.is_empty() {
3327        print_section_header(
3328            "Installed programs",
3329            SectionState::Info,
3330            &format!("{} program(s)", report.installed_programs.len()),
3331        );
3332
3333        let mut programs: Vec<_> = report.installed_programs.iter().collect();
3334        programs.sort_by_key(|(name, _)| *name);
3335        for (program, installed) in programs {
3336            let status_icon = if *installed { "OK".green() } else { "NO".red() };
3337            let status_text = if *installed {
3338                "Installed".green()
3339            } else {
3340                "Missing".red()
3341            };
3342            println!("  {} {:15} {}", status_icon, program, status_text);
3343        }
3344    }
3345
3346    if !report.tool_versions.is_empty() {
3347        print_section_header(
3348            "Tool versions",
3349            SectionState::Info,
3350            &format!("{} tool(s)", report.tool_versions.len()),
3351        );
3352        let mut items: Vec<_> = report.tool_versions.iter().collect();
3353        items.sort_by_key(|(k, _)| *k);
3354        for (name, v) in items {
3355            if v.present {
3356                println!(
3357                    "  {:12} {}",
3358                    name,
3359                    v.version
3360                        .clone()
3361                        .unwrap_or_else(|| "present".to_string())
3362                        .green()
3363                );
3364            } else {
3365                println!("  {:12} {}", name, "not found".red());
3366            }
3367        }
3368    }
3369}
3370fn format_sample_list(items: &[String], limit: usize) -> String {
3371    if items.is_empty() {
3372        return String::new();
3373    }
3374
3375    let shown = items.iter().take(limit).cloned().collect::<Vec<_>>();
3376    let remaining = items.len().saturating_sub(shown.len());
3377    if remaining == 0 {
3378        shown.join(", ")
3379    } else {
3380        format!("{} +{} more", shown.join(", "), remaining)
3381    }
3382}
3383
3384fn format_eta(duration: Duration) -> String {
3385    let total_seconds = duration.as_secs();
3386    let minutes = total_seconds / 60;
3387    let seconds = total_seconds % 60;
3388    if minutes > 0 {
3389        format!("{}m {:02}s", minutes, seconds)
3390    } else {
3391        format!("{}s", seconds)
3392    }
3393}
3394
3395fn estimate_diag_timeout_budget(
3396    ports_count: usize,
3397    skip_speed_test: bool,
3398    include_codetime: bool,
3399) -> Duration {
3400    // This is a conservative upper bound shown to operators while diagnostics runs.
3401    let fixed_seconds =
3402        2 + 2 + 2 + 2 + 3 + 3 + 10 + 4 + PM2_DIAG_TIMEOUT.as_secs() + NGINX_CHECK_TIMEOUT.as_secs();
3403    let port_seconds =
3404        PORT_OWNERSHIP_TIMEOUT.as_secs() + (ports_count as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
3405    let speed_seconds = if skip_speed_test {
3406        0
3407    } else {
3408        INTERNET_SPEED_TIMEOUT.as_secs()
3409    };
3410    let codetime_seconds = if include_codetime {
3411        SYSTEM_INVENTORY_TIMEOUT.as_secs()
3412    } else {
3413        0
3414    };
3415    Duration::from_secs(fixed_seconds + port_seconds + speed_seconds + codetime_seconds)
3416}
3417
3418fn set_step_message(
3419    pb: &ProgressBar,
3420    step_index: usize,
3421    step_total: usize,
3422    started_at: Instant,
3423    estimated_budget: Duration,
3424    label: &str,
3425    timeout: Option<Duration>,
3426) {
3427    let elapsed = started_at.elapsed();
3428    let remaining = estimated_budget.saturating_sub(elapsed);
3429    let timeout_suffix = timeout
3430        .map(|value| format!(" | timeout {}s", value.as_secs()))
3431        .unwrap_or_default();
3432    pb.set_message(format!(
3433        "[{}/{}] {} | ETA {}{}",
3434        step_index,
3435        step_total,
3436        label,
3437        format_eta(remaining),
3438        timeout_suffix
3439    ));
3440}
3441
3442async fn collect_port_ownership_with_timeout() -> BTreeMap<u16, ListeningPortOwnership> {
3443    let task = tokio::task::spawn_blocking(collect_listening_port_ownership);
3444    match tokio::time::timeout(PORT_OWNERSHIP_TIMEOUT, task).await {
3445        Ok(Ok(Ok(ports))) => ports,
3446        Ok(Ok(Err(err))) => {
3447            let _ = log_warn("diag", "Port ownership scan failed", Some(err.as_str())).await;
3448            BTreeMap::new()
3449        }
3450        Ok(Err(err)) => {
3451            let message = format!("Port ownership scan task failed: {}", err);
3452            let _ = log_warn("diag", "Port ownership scan failed", Some(message.as_str())).await;
3453            BTreeMap::new()
3454        }
3455        Err(_) => {
3456            let timeout_note = format!(
3457                "Port ownership scan timed out after {} seconds",
3458                PORT_OWNERSHIP_TIMEOUT.as_secs()
3459            );
3460            let _ = log_warn(
3461                "diag",
3462                "Port ownership scan timed out",
3463                Some(timeout_note.as_str()),
3464            )
3465            .await;
3466            BTreeMap::new()
3467        }
3468    }
3469}
3470
3471async fn sync_system_inventory_with_timeout(include_cursor: bool) -> Option<CodeTimeDiagnostic> {
3472    let current_dir = env::current_dir().ok();
3473    let task = tokio::task::spawn_blocking(move || {
3474        sync_system_inventory(true, include_cursor, current_dir.as_deref())
3475    });
3476
3477    match tokio::time::timeout(SYSTEM_INVENTORY_TIMEOUT, task).await {
3478        Ok(Ok(Ok(result))) => Some(CodeTimeDiagnostic {
3479            saved_config_path: result.config_path.display().to_string(),
3480            refreshed: result.refreshed,
3481            inventory: result.inventory,
3482        }),
3483        Ok(Ok(Err(err))) => {
3484            let _ = log_warn("diag", "System inventory sync failed", Some(err.as_str())).await;
3485            None
3486        }
3487        Ok(Err(err)) => {
3488            let message = format!("System inventory task failed: {}", err);
3489            let _ = log_warn(
3490                "diag",
3491                "System inventory sync failed",
3492                Some(message.as_str()),
3493            )
3494            .await;
3495            None
3496        }
3497        Err(_) => {
3498            let message = format!(
3499                "System inventory sync timed out after {} seconds",
3500                SYSTEM_INVENTORY_TIMEOUT.as_secs()
3501            );
3502            let _ = log_warn(
3503                "diag",
3504                "System inventory sync timed out",
3505                Some(message.as_str()),
3506            )
3507            .await;
3508            None
3509        }
3510    }
3511}
3512
3513pub async fn run_full_diagnostics(
3514    ports: Vec<u16>,
3515    options: DiagnosticRunOptions,
3516) -> Result<DiagnosticReport> {
3517    let _ = log_info("diag", "Running system diagnostics...", None).await;
3518
3519    let skip_speed = options.skip_speed_test || options.quick;
3520    let do_http = (options.http_probe || options.full) && !options.quick;
3521    let do_deep = options.full && !options.quick;
3522    let estimated_budget =
3523        estimate_diag_timeout_budget(ports.len(), skip_speed, options.codetime);
3524    let timeout_summary = format!(
3525        "Estimated max runtime {} (PM2 {}s, ports {}s + {}s/port{}{})",
3526        format_eta(estimated_budget),
3527        PM2_DIAG_TIMEOUT.as_secs(),
3528        PORT_OWNERSHIP_TIMEOUT.as_secs(),
3529        PER_PORT_CHECK_TIMEOUT.as_secs(),
3530        if skip_speed {
3531            ", speed test skipped"
3532        } else {
3533            ", speed test up to 20s"
3534        },
3535        if options.codetime {
3536            ", codetime inventory up to 25s"
3537        } else {
3538            ""
3539        }
3540    );
3541    let _ = log_info(
3542        "diag",
3543        "Diagnostics timeout budget",
3544        Some(timeout_summary.as_str()),
3545    )
3546    .await;
3547
3548    let pb: ProgressBar = ProgressBar::new_spinner();
3549    pb.enable_steady_tick(Duration::from_millis(80));
3550    pb.set_style(
3551        ProgressStyle::with_template("{spinner} {msg}")
3552            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
3553    );
3554    let started_at = Instant::now();
3555    let mut step_total = if options.codetime { 14 } else { 13 };
3556    if do_deep {
3557        step_total += 4;
3558    } else if do_http {
3559        step_total += 1;
3560    }
3561    let mut step_index = 1usize;
3562
3563    set_step_message(
3564        &pb,
3565        step_index,
3566        step_total,
3567        started_at,
3568        estimated_budget,
3569        "Collecting system metrics",
3570        None,
3571    );
3572    step_index += 1;
3573    let system_metrics = get_system_metrics().await?;
3574
3575    set_step_message(
3576        &pb,
3577        step_index,
3578        step_total,
3579        started_at,
3580        estimated_budget,
3581        "Collecting OS/CPU/GPU",
3582        None,
3583    );
3584    step_index += 1;
3585    let os = get_os_info().await;
3586    let cpu = get_cpu_info().await;
3587    let gpu_candidates = get_gpu_candidates().await;
3588
3589    set_step_message(
3590        &pb,
3591        step_index,
3592        step_total,
3593        started_at,
3594        estimated_budget,
3595        "Collecting disks",
3596        None,
3597    );
3598    step_index += 1;
3599    let disks = get_disk_infos().await;
3600
3601    let codetime = if options.codetime {
3602        set_step_message(
3603            &pb,
3604            step_index,
3605            step_total,
3606            started_at,
3607            estimated_budget,
3608            if options.cursor {
3609                "Refreshing CodeTime + Cursor inventory"
3610            } else {
3611                "Refreshing CodeTime inventory"
3612            },
3613            Some(SYSTEM_INVENTORY_TIMEOUT),
3614        );
3615        step_index += 1;
3616        sync_system_inventory_with_timeout(options.cursor).await
3617    } else {
3618        None
3619    };
3620
3621    set_step_message(
3622        &pb,
3623        step_index,
3624        step_total,
3625        started_at,
3626        estimated_budget,
3627        "Collecting shell + clipboard",
3628        None,
3629    );
3630    step_index += 1;
3631    let shell = get_shell_info();
3632    let clipboard_tools = get_clipboard_tools().await;
3633
3634    set_step_message(
3635        &pb,
3636        step_index,
3637        step_total,
3638        started_at,
3639        estimated_budget,
3640        "Detecting proxies",
3641        None,
3642    );
3643    step_index += 1;
3644    let proxy_detection = get_proxy_detection().await;
3645
3646    set_step_message(
3647        &pb,
3648        step_index,
3649        step_total,
3650        started_at,
3651        estimated_budget,
3652        "Checking network/public IP",
3653        None,
3654    );
3655    step_index += 1;
3656    let connectivity = check_internet_connectivity().await.unwrap_or(false);
3657    let exposure = get_exposure_info(connectivity).await;
3658
3659    set_step_message(
3660        &pb,
3661        step_index,
3662        step_total,
3663        started_at,
3664        estimated_budget,
3665        "Checking tools",
3666        Some(TOOL_VERSION_TIMEOUT),
3667    );
3668    step_index += 1;
3669    let installed_programs = check_installed_programs().await;
3670    let tool_versions = get_tool_versions().await;
3671
3672    set_step_message(
3673        &pb,
3674        step_index,
3675        step_total,
3676        started_at,
3677        estimated_budget,
3678        "Checking file permissions",
3679        None,
3680    );
3681    step_index += 1;
3682    let path_permission_checks = get_path_permission_checks().await;
3683
3684    set_step_message(
3685        &pb,
3686        step_index,
3687        step_total,
3688        started_at,
3689        estimated_budget,
3690        "Checking services",
3691        None,
3692    );
3693    step_index += 1;
3694    let service_statuses = get_service_statuses().await;
3695
3696    set_step_message(
3697        &pb,
3698        step_index,
3699        step_total,
3700        started_at,
3701        estimated_budget,
3702        "Checking PM2 process table",
3703        Some(PM2_DIAG_TIMEOUT),
3704    );
3705    step_index += 1;
3706    let pm2_process_count = get_pm2_process_count().await;
3707
3708    let ports_timeout_budget = PORT_OWNERSHIP_TIMEOUT
3709        + Duration::from_secs(ports.len() as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
3710    set_step_message(
3711        &pb,
3712        step_index,
3713        step_total,
3714        started_at,
3715        estimated_budget,
3716        "Checking ports",
3717        Some(ports_timeout_budget),
3718    );
3719    step_index += 1;
3720    let port_ownership = collect_port_ownership_with_timeout().await;
3721
3722    let mut port_checks = Vec::new();
3723    for port in ports {
3724        let timed_check = tokio::time::timeout(
3725            PER_PORT_CHECK_TIMEOUT,
3726            check_port_availability(port, port_ownership.get(&port)),
3727        )
3728        .await;
3729        match timed_check {
3730            Ok(Ok(check)) => port_checks.push(check),
3731            Ok(Err(err)) => {
3732                let details = format!("Port {} check failed: {}", port, err);
3733                let _ = log_warn("diag", "Port check failed", Some(details.as_str())).await;
3734            }
3735            Err(_) => {
3736                let details = format!(
3737                    "Port {} check timed out after {} seconds",
3738                    port,
3739                    PER_PORT_CHECK_TIMEOUT.as_secs()
3740                );
3741                let _ = log_warn("diag", "Port check timed out", Some(details.as_str())).await;
3742            }
3743        }
3744    }
3745
3746    set_step_message(
3747        &pb,
3748        step_index,
3749        step_total,
3750        started_at,
3751        estimated_budget,
3752        "Measuring internet speed",
3753        Some(INTERNET_SPEED_TIMEOUT),
3754    );
3755    step_index += 1;
3756    let internet_speed = if skip_speed {
3757        let _ = log_info(
3758            "diag",
3759            "Skipping internet speed test (--no-speed-test or --quick)",
3760            None,
3761        )
3762        .await;
3763        None
3764    } else if connectivity {
3765        match tokio::time::timeout(INTERNET_SPEED_TIMEOUT, measure_internet_speed()).await {
3766            Ok(Ok(speed)) => Some(speed),
3767            Ok(Err(err)) => {
3768                let details = format!("Internet speed probe failed: {}", err);
3769                let _ = log_warn(
3770                    "diag",
3771                    "Internet speed probe failed",
3772                    Some(details.as_str()),
3773                )
3774                .await;
3775                None
3776            }
3777            Err(_) => {
3778                let details = format!(
3779                    "Internet speed probe timed out after {} seconds",
3780                    INTERNET_SPEED_TIMEOUT.as_secs()
3781                );
3782                let _ = log_warn(
3783                    "diag",
3784                    "Internet speed probe timed out",
3785                    Some(details.as_str()),
3786                )
3787                .await;
3788                None
3789            }
3790        }
3791    } else {
3792        None
3793    };
3794
3795    set_step_message(
3796        &pb,
3797        step_index,
3798        step_total,
3799        started_at,
3800        estimated_budget,
3801        "Checking nginx",
3802        Some(NGINX_CHECK_TIMEOUT),
3803    );
3804    let nginx_status = match tokio::time::timeout(NGINX_CHECK_TIMEOUT, check_nginx_status()).await {
3805        Ok(Ok(status)) => Some(status),
3806        Ok(Err(err)) => {
3807            let details = format!("Nginx status check failed: {}", err);
3808            let _ = log_warn("diag", "Nginx status check failed", Some(details.as_str())).await;
3809            None
3810        }
3811        Err(_) => {
3812            let details = format!(
3813                "Nginx status check timed out after {} seconds",
3814                NGINX_CHECK_TIMEOUT.as_secs()
3815            );
3816            let _ = log_warn(
3817                "diag",
3818                "Nginx status check timed out",
3819                Some(details.as_str()),
3820            )
3821            .await;
3822            None
3823        }
3824    };
3825
3826    let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3827    let config = load_xbp_config().await.ok();
3828    let project_config_present = config.is_some();
3829    let service_rows: Vec<(String, u16, Option<String>)> = config
3830        .as_ref()
3831        .map(|cfg| {
3832            get_all_services(cfg)
3833                .into_iter()
3834                .map(|service| (service.name.clone(), service.port, service.url.clone()))
3835                .collect()
3836        })
3837        .unwrap_or_default();
3838    let configured_services = service_rows
3839        .iter()
3840        .map(|(name, port, _)| format!("{name}:{port}"))
3841        .collect::<Vec<_>>();
3842    let provider_manifests: Vec<String> = ProjectDetector::detect_provider_manifests(&current_dir);
3843    let env_files = discover_env_files(&current_dir);
3844    let hostname = System::host_name();
3845    let local_ips = collect_local_ips().await;
3846    let network_interfaces = collect_network_interfaces();
3847
3848    let dns_checks = if options.quick {
3849        Vec::new()
3850    } else {
3851        set_step_message(
3852            &pb,
3853            step_index,
3854            step_total,
3855            started_at,
3856            estimated_budget,
3857            "Resolving DNS",
3858            None,
3859        );
3860        step_index += 1;
3861        let mut hosts = vec!["github.com", "crates.io", "registry.npmjs.org", "1.1.1.1"];
3862        if do_deep {
3863            hosts.extend(["docs.xbp.app", "api.xbp.app", "xbp.app"]);
3864        }
3865        collect_dns_checks(&hosts).await
3866    };
3867
3868    let http_probes = if do_http && !service_rows.is_empty() {
3869        set_step_message(
3870            &pb,
3871            step_index,
3872            step_total,
3873            started_at,
3874            estimated_budget,
3875            "Probing service HTTP endpoints",
3876            None,
3877        );
3878        step_index += 1;
3879        collect_http_probes(&service_rows).await
3880    } else {
3881        Vec::new()
3882    };
3883
3884    let git = if do_deep || !options.quick {
3885        set_step_message(
3886            &pb,
3887            step_index,
3888            step_total,
3889            started_at,
3890            estimated_budget,
3891            "Checking git hygiene",
3892            None,
3893        );
3894        step_index += 1;
3895        collect_git_hygiene(&current_dir).await
3896    } else {
3897        None
3898    };
3899
3900    let cli_auth = if do_deep {
3901        set_step_message(
3902            &pb,
3903            step_index,
3904            step_total,
3905            started_at,
3906            estimated_budget,
3907            "Checking CLI session",
3908            None,
3909        );
3910        step_index += 1;
3911        Some(collect_cli_auth_diagnostic().await)
3912    } else {
3913        None
3914    };
3915
3916    let docker = if do_deep || cfg!(feature = "docker") {
3917        set_step_message(
3918            &pb,
3919            step_index,
3920            step_total,
3921            started_at,
3922            estimated_budget,
3923            "Checking Docker",
3924            None,
3925        );
3926        let _ = step_index;
3927        Some(collect_docker_diagnostic().await)
3928    } else {
3929        None
3930    };
3931
3932    let mut feature_flags: HashMap<String, bool> = HashMap::new();
3933    feature_flags.insert("monitoring".to_string(), cfg!(feature = "monitoring"));
3934    feature_flags.insert(
3935        "kafka".to_string(),
3936        cfg!(all(feature = "kafka", not(windows))),
3937    );
3938    feature_flags.insert("kubernetes".to_string(), cfg!(feature = "kubernetes"));
3939    feature_flags.insert("docker".to_string(), cfg!(feature = "docker"));
3940    feature_flags.insert("linear".to_string(), cfg!(feature = "linear"));
3941    feature_flags.insert("secrets".to_string(), cfg!(feature = "secrets"));
3942    feature_flags.insert("athena".to_string(), cfg!(feature = "athena"));
3943    feature_flags.insert("nordvpn".to_string(), cfg!(feature = "nordvpn"));
3944    feature_flags.insert("openapi-gen".to_string(), cfg!(feature = "openapi-gen"));
3945
3946    let xbp_cli_version = env!("CARGO_PKG_VERSION").to_string();
3947    let worktree_watch = Some(collect_worktree_watch_diagnostic());
3948
3949    pb.finish_and_clear();
3950
3951    let mut report = DiagnosticReport {
3952        system_metrics,
3953        os,
3954        cpu,
3955        gpu_candidates,
3956        disks,
3957        shell,
3958        proxy_detection,
3959        clipboard_tools,
3960        exposure,
3961        pm2_process_count,
3962        tool_versions,
3963        service_statuses,
3964        feature_flags,
3965        xbp_cli_version,
3966        hostname,
3967        cwd: current_dir.display().to_string(),
3968        local_ips,
3969        network_interfaces,
3970        project_config_present,
3971        configured_services,
3972        provider_manifests,
3973        env_files,
3974        nginx_status,
3975        port_checks,
3976        internet_speed,
3977        connectivity,
3978        dns_checks,
3979        http_probes,
3980        git,
3981        cli_auth,
3982        docker,
3983        installed_programs,
3984        path_permission_checks,
3985        codetime,
3986        worktree_watch,
3987        recommendations: Vec::new(),
3988        scorecard: DiagScorecard {
3989            healthy: 0,
3990            warnings: 0,
3991            failures: 0,
3992            grade: "OK".into(),
3993        },
3994    };
3995    report.recommendations = build_recommendations(&report);
3996    report.scorecard = build_scorecard(&report);
3997    Ok(report)
3998}
3999
4000/// Print the full report as pretty JSON (for `--json`).
4001pub fn print_diagnostic_report_json(report: &DiagnosticReport) -> Result<()> {
4002    let json = serde_json::to_string_pretty(report)?;
4003    println!("{json}");
4004    Ok(())
4005}
4006
4007#[cfg(test)]
4008mod expanded_diag_tests {
4009    use super::*;
4010
4011    fn minimal_report() -> DiagnosticReport {
4012        DiagnosticReport {
4013            system_metrics: SystemMetrics {
4014                cpu_usage: 10.0,
4015                memory_total: 1000,
4016                memory_used: 100,
4017                memory_percent: 10.0,
4018                disk_total: 1000,
4019                disk_used: 100,
4020                disk_percent: 10.0,
4021                network_rx: 0,
4022                network_tx: 0,
4023                uptime: 100,
4024                process_count: 1,
4025            },
4026            os: None,
4027            cpu: None,
4028            gpu_candidates: Vec::new(),
4029            disks: Vec::new(),
4030            shell: None,
4031            proxy_detection: None,
4032            clipboard_tools: HashMap::new(),
4033            exposure: None,
4034            pm2_process_count: Some(0),
4035            tool_versions: HashMap::new(),
4036            service_statuses: HashMap::new(),
4037            feature_flags: HashMap::new(),
4038            xbp_cli_version: "0.0.0".into(),
4039            hostname: Some("test".into()),
4040            cwd: "/tmp".into(),
4041            local_ips: vec!["127.0.0.1".into()],
4042            network_interfaces: Vec::new(),
4043            project_config_present: true,
4044            configured_services: Vec::new(),
4045            provider_manifests: Vec::new(),
4046            env_files: Vec::new(),
4047            nginx_status: None,
4048            port_checks: Vec::new(),
4049            internet_speed: None,
4050            connectivity: true,
4051            dns_checks: Vec::new(),
4052            http_probes: Vec::new(),
4053            git: None,
4054            cli_auth: None,
4055            docker: None,
4056            installed_programs: HashMap::new(),
4057            path_permission_checks: Vec::new(),
4058            codetime: None,
4059            worktree_watch: None,
4060            recommendations: Vec::new(),
4061            scorecard: DiagScorecard {
4062                healthy: 0,
4063                warnings: 0,
4064                failures: 0,
4065                grade: "OK".into(),
4066            },
4067        }
4068    }
4069
4070    #[test]
4071    fn scorecard_is_ok_when_healthy() {
4072        let report = minimal_report();
4073        let card = build_scorecard(&report);
4074        assert_eq!(card.grade, "OK");
4075        assert!(card.failures == 0);
4076    }
4077
4078    #[test]
4079    fn scorecard_fails_when_offline() {
4080        let mut report = minimal_report();
4081        report.connectivity = false;
4082        let card = build_scorecard(&report);
4083        assert_eq!(card.grade, "FAIL");
4084        assert!(card.failures > 0);
4085    }
4086
4087    #[test]
4088    fn recommendations_suggest_login_when_signed_out() {
4089        let mut report = minimal_report();
4090        report.cli_auth = Some(CliAuthDiagnostic {
4091            logged_in: false,
4092            user_email: None,
4093            user_name: None,
4094            token_prefix: None,
4095            message: "not logged in".into(),
4096        });
4097        let recs = build_recommendations(&report);
4098        assert!(recs.iter().any(|r| r.contains("xbp login")));
4099    }
4100
4101    #[test]
4102    fn recommendations_empty_critical_path_gives_ready_message() {
4103        let mut report = minimal_report();
4104        // Pretend git is present so the only tip is the healthy default.
4105        report.installed_programs.insert("git".into(), true);
4106        let recs = build_recommendations(&report);
4107        assert!(
4108            recs.iter().any(|r| r.contains("ready") || r.contains("No critical")),
4109            "got {recs:?}"
4110        );
4111    }
4112
4113    #[test]
4114    fn scorecard_warns_without_project_config() {
4115        let mut report = minimal_report();
4116        report.project_config_present = false;
4117        let card = build_scorecard(&report);
4118        assert!(card.warnings > 0 || card.grade == "WARN" || card.grade == "OK");
4119        // missing project is a warning, not necessarily FAIL
4120        assert_ne!(card.grade, "FAIL");
4121    }
4122
4123    #[test]
4124    fn scorecard_fails_on_blocked_ports() {
4125        let mut report = minimal_report();
4126        report.port_checks.push(PortCheck {
4127            port: 80,
4128            is_open: false,
4129            is_blocked: true,
4130            pids: Vec::new(),
4131            xbp_projects: Vec::new(),
4132        });
4133        let card = build_scorecard(&report);
4134        assert_eq!(card.grade, "FAIL");
4135    }
4136
4137    #[test]
4138    fn scorecard_warns_on_high_disk() {
4139        let mut report = minimal_report();
4140        report.system_metrics.disk_percent = 85.0;
4141        let card = build_scorecard(&report);
4142        assert!(card.warnings > 0 || card.grade == "WARN");
4143    }
4144
4145    #[test]
4146    fn scorecard_fails_on_critical_disk() {
4147        let mut report = minimal_report();
4148        report.system_metrics.disk_percent = 95.0;
4149        let card = build_scorecard(&report);
4150        assert_eq!(card.grade, "FAIL");
4151    }
4152
4153    #[test]
4154    fn scorecard_http_partial_is_warn() {
4155        let mut report = minimal_report();
4156        report.http_probes = vec![
4157            HttpProbe {
4158                name: "a".into(),
4159                url: "http://a".into(),
4160                ok: true,
4161                status: Some(200),
4162                latency_ms: Some(1),
4163                error: None,
4164            },
4165            HttpProbe {
4166                name: "b".into(),
4167                url: "http://b".into(),
4168                ok: false,
4169                status: None,
4170                latency_ms: None,
4171                error: Some("down".into()),
4172            },
4173        ];
4174        let card = build_scorecard(&report);
4175        assert!(matches!(card.grade.as_str(), "WARN" | "FAIL" | "OK"));
4176        // partial HTTP should not be pure healthy-only without warnings/failures accounting
4177        assert!(card.warnings + card.failures >= 1 || card.healthy >= 1);
4178    }
4179
4180    #[test]
4181    fn recommendations_disk_memory_and_docker() {
4182        let mut report = minimal_report();
4183        report.installed_programs.insert("git".into(), true);
4184        report.system_metrics.disk_percent = 90.0;
4185        report.system_metrics.memory_percent = 95.0;
4186        report.docker = Some(DockerDiagnostic {
4187            cli_present: true,
4188            daemon_ok: false,
4189            version: Some("24".into()),
4190            compose_present: true,
4191            note: Some("down".into()),
4192        });
4193        report.disks.push(DiskInfo {
4194            mount: "/".into(),
4195            fs: Some("ext4".into()),
4196            total: 100,
4197            used: 90,
4198            percent: 90.0,
4199            bar: String::new(),
4200            is_current: true,
4201        });
4202        let recs = build_recommendations(&report);
4203        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("disk")));
4204        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("memory")));
4205        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("docker")));
4206    }
4207
4208    #[test]
4209    fn recommendations_blocked_ports_and_http_and_dns() {
4210        let mut report = minimal_report();
4211        report.installed_programs.insert("git".into(), true);
4212        report.port_checks.push(PortCheck {
4213            port: 443,
4214            is_open: false,
4215            is_blocked: true,
4216            pids: Vec::new(),
4217            xbp_projects: Vec::new(),
4218        });
4219        report.http_probes.push(HttpProbe {
4220            name: "api".into(),
4221            url: "http://127.0.0.1:1/".into(),
4222            ok: false,
4223            status: None,
4224            latency_ms: None,
4225            error: Some("nope".into()),
4226        });
4227        report.dns_checks.push(DnsCheck {
4228            host: "example.invalid".into(),
4229            ok: false,
4230            addresses: Vec::new(),
4231            error: Some("nxdomain".into()),
4232        });
4233        let recs = build_recommendations(&report);
4234        assert!(recs.iter().any(|r| r.contains("firewall") || r.contains("port")));
4235        assert!(recs.iter().any(|r| r.contains("HTTP") || r.contains("api")));
4236        assert!(recs.iter().any(|r| r.contains("DNS") || r.contains("example.invalid")));
4237    }
4238
4239    #[test]
4240    fn recommendations_nginx_invalid_and_dirty_git() {
4241        let mut report = minimal_report();
4242        report.installed_programs.insert("git".into(), true);
4243        report.nginx_status = Some(NginxStatus {
4244            is_running: true,
4245            is_enabled: true,
4246            config_valid: false,
4247            error: None,
4248        });
4249        report.git = Some(GitHygiene {
4250            is_repo: true,
4251            branch: Some("main".into()),
4252            dirty: Some(true),
4253            ahead: None,
4254            behind: None,
4255            remote: None,
4256            notes: vec!["dirty".into()],
4257            credential_helper: None,
4258        });
4259        let recs = build_recommendations(&report);
4260        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("nginx")));
4261        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("git")));
4262    }
4263
4264    #[test]
4265    fn recommendations_missing_git() {
4266        let mut report = minimal_report();
4267        report.installed_programs.insert("git".into(), false);
4268        let recs = build_recommendations(&report);
4269        assert!(recs.iter().any(|r| r.contains("git")));
4270    }
4271
4272    #[test]
4273    fn credential_helper_executable_parses_bang_and_plain() {
4274        assert_eq!(
4275            credential_helper_executable("!/usr/bin/gh auth git-credential").as_deref(),
4276            Some("/usr/bin/gh")
4277        );
4278        assert_eq!(
4279            credential_helper_executable("/snap/gh/751/gh auth git-credential").as_deref(),
4280            Some("/snap/gh/751/gh")
4281        );
4282        assert_eq!(credential_helper_executable("store"), None);
4283        assert_eq!(credential_helper_executable("manager-core"), None);
4284    }
4285
4286    #[test]
4287    fn is_stale_gh_helper_detects_missing_snap_path() {
4288        // Revision-specific snap path almost never exists in unit test envs.
4289        let helper = "/snap/gh/751/gh auth git-credential";
4290        assert!(is_stale_gh_credential_helper(helper));
4291        assert!(is_stale_gh_credential_helper(&format!("!{helper}")));
4292        assert!(!is_stale_gh_credential_helper("store"));
4293        assert!(!is_stale_gh_credential_helper("cache --timeout=3600"));
4294    }
4295
4296    #[test]
4297    fn recommendations_stale_snap_gh_credential_helper() {
4298        let mut report = minimal_report();
4299        report.installed_programs.insert("git".into(), true);
4300        report.git = Some(GitHygiene {
4301            is_repo: true,
4302            branch: Some("main".into()),
4303            dirty: Some(false),
4304            ahead: None,
4305            behind: None,
4306            remote: None,
4307            notes: vec![],
4308            credential_helper: Some(GitCredentialHelperDiag {
4309                helpers: vec!["/snap/gh/751/gh auth git-credential".into()],
4310                stale_helpers: vec!["/snap/gh/751/gh auth git-credential".into()],
4311                gh_path: None,
4312                fix_attempted: false,
4313                fixed: false,
4314                message: Some("stale".into()),
4315            }),
4316        });
4317        let recs = build_recommendations(&report);
4318        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("snap")
4319            || r.to_ascii_lowercase().contains("credential.helper")));
4320        let card = build_scorecard(&report);
4321        assert_eq!(card.grade, "FAIL");
4322    }
4323
4324    #[test]
4325    fn recommendations_fixed_snap_gh_credential_helper() {
4326        let mut report = minimal_report();
4327        report.installed_programs.insert("git".into(), true);
4328        report.git = Some(GitHygiene {
4329            is_repo: true,
4330            branch: Some("main".into()),
4331            dirty: Some(false),
4332            ahead: None,
4333            behind: None,
4334            remote: None,
4335            notes: vec![],
4336            credential_helper: Some(GitCredentialHelperDiag {
4337                helpers: vec!["!/usr/bin/gh auth git-credential".into()],
4338                stale_helpers: vec![],
4339                gh_path: Some("/usr/bin/gh".into()),
4340                fix_attempted: true,
4341                fixed: true,
4342                message: Some("Repaired".into()),
4343            }),
4344        });
4345        let recs = build_recommendations(&report);
4346        assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("repaired")));
4347    }
4348
4349    #[test]
4350    fn summarize_port_checks_counts() {
4351        let mut report = minimal_report();
4352        report.port_checks = vec![
4353            PortCheck {
4354                port: 1,
4355                is_open: true,
4356                is_blocked: false,
4357                pids: Vec::new(),
4358                xbp_projects: Vec::new(),
4359            },
4360            PortCheck {
4361                port: 2,
4362                is_open: false,
4363                is_blocked: true,
4364                pids: Vec::new(),
4365                xbp_projects: Vec::new(),
4366            },
4367            PortCheck {
4368                port: 3,
4369                is_open: true,
4370                is_blocked: false,
4371                pids: Vec::new(),
4372                xbp_projects: Vec::new(),
4373            },
4374        ];
4375        let (open, blocked, total) = super::summarize_port_checks(&report);
4376        assert_eq!(open, 2);
4377        assert_eq!(blocked, 1);
4378        assert_eq!(total, 3);
4379    }
4380
4381    #[test]
4382    fn summarize_path_permissions_counts_pass_fail() {
4383        let mut report = minimal_report();
4384        report.path_permission_checks = vec![
4385            PathPermissionCheck {
4386                path: "/a".into(),
4387                purpose: "x".into(),
4388                required: "read".into(),
4389                exists: true,
4390                readable: true,
4391                writable: true,
4392                creatable: true,
4393                ok: true,
4394                message: None,
4395            },
4396            PathPermissionCheck {
4397                path: "/b".into(),
4398                purpose: "y".into(),
4399                required: "write".into(),
4400                exists: false,
4401                readable: false,
4402                writable: false,
4403                creatable: false,
4404                ok: false,
4405                message: Some("no".into()),
4406            },
4407        ];
4408        let (pass, total) = super::summarize_path_permissions(&report);
4409        assert_eq!(pass, 1);
4410        assert_eq!(total, 2);
4411    }
4412
4413    #[test]
4414    fn discover_env_files_finds_dotenv() {
4415        let dir = std::env::temp_dir().join(format!(
4416            "xbp-diag-env-{}",
4417            std::time::SystemTime::now()
4418                .duration_since(std::time::UNIX_EPOCH)
4419                .map(|d| d.as_nanos())
4420                .unwrap_or(0)
4421        ));
4422        fs::create_dir_all(&dir).unwrap();
4423        fs::write(dir.join(".env"), "A=1\n").unwrap();
4424        fs::write(dir.join(".env.local"), "B=2\n").unwrap();
4425        let found = discover_env_files(&dir);
4426        assert!(found.iter().any(|f| f == ".env"));
4427        assert!(found.iter().any(|f| f == ".env.local"));
4428        let _ = fs::remove_dir_all(dir);
4429    }
4430
4431    #[test]
4432    fn diag_bool_and_ratio_helpers() {
4433        assert!(diag_bool(true).to_string().contains("true"));
4434        assert!(diag_bool(false).to_string().contains("false"));
4435        assert!(diag_ratio(2, 5).contains('2'));
4436        assert!(diag_bool_fragment("x", true).contains("x="));
4437    }
4438
4439    #[test]
4440    fn color_service_status_classifies() {
4441        assert!(color_service_status("running").to_string().contains("running"));
4442        assert!(color_service_status("failed").to_string().contains("failed"));
4443        assert!(color_service_status("degraded").to_string().contains("degraded"));
4444    }
4445
4446    #[test]
4447    fn render_feature_flags_sorted() {
4448        let mut flags = HashMap::new();
4449        flags.insert("z".into(), true);
4450        flags.insert("a".into(), false);
4451        let s = render_feature_flags(&flags);
4452        assert!(s.find('a').unwrap() < s.find('z').unwrap());
4453    }
4454
4455    #[test]
4456    fn section_badge_variants() {
4457        for state in [
4458            SectionState::Healthy,
4459            SectionState::Warn,
4460            SectionState::Fail,
4461            SectionState::Info,
4462        ] {
4463            assert!(!section_badge(state).to_string().is_empty());
4464        }
4465    }
4466}