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